Merge branch 'master' into split_start_script

This commit is contained in:
einhornimmond 2025-05-19 15:25:03 +02:00
commit fe1bd899ef
65 changed files with 1597 additions and 213 deletions

View File

@ -42,6 +42,11 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set Node.js version
uses: actions/setup-node@v4
with:
node-version: '18.20.7'
- name: Database | docker-compose - name: Database | docker-compose
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach mariadb run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach mariadb

View File

@ -9,6 +9,11 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set Node.js version
uses: actions/setup-node@v4
with:
node-version: '18.20.7'
- name: install bun - name: install bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2

2
.nvmrc
View File

@ -1 +1 @@
v18.20.7 v18.20.7

View File

@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/ban-types */
declare module 'random-bigint' { declare module 'random-bigint' {
function random(bits: number, cb?: (err: Error, num: BigInt) => void): BigInt function random(bits: number, cb?: (err: Error, num: BigInt) => void): BigInt
export = random export = random

View File

@ -1,4 +1,4 @@
// eslint-disable-next-line import/no-unresolved
export * from '@/node_modules/@types/sodium-native' export * from '@/node_modules/@types/sodium-native'
declare module 'sodium-native' { declare module 'sodium-native' {

View File

@ -1,5 +1,4 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
// eslint-disable-next-line import/no-commonjs, import/unambiguous
module.exports = { module.exports = {
verbose: true, verbose: true,
preset: 'ts-jest', preset: 'ts-jest',
@ -7,7 +6,7 @@ module.exports = {
collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'], collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'],
coverageThreshold: { coverageThreshold: {
global: { global: {
lines: 77, lines: 75,
}, },
}, },
setupFiles: ['<rootDir>/test/testSetup.ts'], setupFiles: ['<rootDir>/test/testSetup.ts'],
@ -25,22 +24,18 @@ module.exports = {
'@typeorm/(.*)': '<rootDir>/src/typeorm/$1', '@typeorm/(.*)': '<rootDir>/src/typeorm/$1',
'@test/(.*)': '<rootDir>/test/$1', '@test/(.*)': '<rootDir>/test/$1',
'@entity/(.*)': '@entity/(.*)':
// eslint-disable-next-line n/no-process-env
process.env.NODE_ENV === 'development' process.env.NODE_ENV === 'development'
? '<rootDir>/../database/entity/$1' ? '<rootDir>/../database/entity/$1'
: '<rootDir>/../database/build/entity/$1', : '<rootDir>/../database/build/entity/$1',
'@logging/(.*)': '@logging/(.*)':
// eslint-disable-next-line n/no-process-env
process.env.NODE_ENV === 'development' process.env.NODE_ENV === 'development'
? '<rootDir>/../database/logging/$1' ? '<rootDir>/../database/logging/$1'
: '<rootDir>/../database/build/logging/$1', : '<rootDir>/../database/build/logging/$1',
'@dbTools/(.*)': '@dbTools/(.*)':
// eslint-disable-next-line n/no-process-env
process.env.NODE_ENV === 'development' process.env.NODE_ENV === 'development'
? '<rootDir>/../database/src/$1' ? '<rootDir>/../database/src/$1'
: '<rootDir>/../database/build/src/$1', : '<rootDir>/../database/build/src/$1',
'@config/(.*)': '@config/(.*)':
// eslint-disable-next-line n/no-process-env
process.env.NODE_ENV === 'development' process.env.NODE_ENV === 'development'
? '<rootDir>/../config/src/$1' ? '<rootDir>/../config/src/$1'
: '<rootDir>/../config/build/$1', : '<rootDir>/../config/build/$1',

View File

@ -11,15 +11,16 @@
"build": "ts-node ./esbuild.config.ts && mkdirp build/templates/ && ncp src/emails/templates build/templates && mkdirp locales/ && ncp src/locales locales", "build": "ts-node ./esbuild.config.ts && mkdirp build/templates/ && ncp src/emails/templates build/templates && mkdirp locales/ && ncp src/locales locales",
"clean": "tsc --build --clean", "clean": "tsc --build --clean",
"dev": "cross-env TZ=UTC nodemon -w src --ext ts,pug,json,css -r tsconfig-paths/register src/index.ts", "dev": "cross-env TZ=UTC nodemon -w src --ext ts,pug,json,css -r tsconfig-paths/register src/index.ts",
"test": "cross-env TZ=UTC NODE_ENV=development DB_DATABASE=gradido_test_backend jest --runInBand --forceExit --detectOpenHandles",
"seed": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/seeds/index.ts",
"klicktipp": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/util/executeKlicktipp.ts",
"gmsusers": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/apis/gms/ExportUsers.ts", "gmsusers": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/apis/gms/ExportUsers.ts",
"humhubUserExport": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/apis/humhub/ExportUsers.ts", "humhubUserExport": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/apis/humhub/ExportUsers.ts",
"klicktipp": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/util/executeKlicktipp.ts",
"lint": "biome check --error-on-warnings .", "lint": "biome check --error-on-warnings .",
"lint:fix": "biome check --error-on-warnings . --write", "lint:fix": "biome check --error-on-warnings . --write",
"lint:fix:unsafe": "biome check --fix --unsafe",
"locales": "scripts/sort.sh", "locales": "scripts/sort.sh",
"seed": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/seeds/index.ts",
"start": "cross-env TZ=UTC NODE_ENV=production node build/index.js", "start": "cross-env TZ=UTC NODE_ENV=production node build/index.js",
"test": "cross-env TZ=UTC NODE_ENV=development DB_DATABASE=gradido_test_backend jest --runInBand --forceExit --detectOpenHandles",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"nodemonConfig": { "nodemonConfig": {

View File

@ -1,4 +1,3 @@
/* eslint-disable camelcase */
import { User } from 'database' import { User } from 'database'
import { convertGradidoLanguageToHumhub } from '@/apis/humhub/convertLanguage' import { convertGradidoLanguageToHumhub } from '@/apis/humhub/convertLanguage'

View File

@ -49,7 +49,11 @@ export async function syncUser(
if (!isValid(postUser, user.id)) { if (!isValid(postUser, user.id)) {
return ExecutedHumhubAction.VALIDATION_ERROR return ExecutedHumhubAction.VALIDATION_ERROR
} }
const humhubUser = humhubUsers.get(postUser.account.username) let humhubUser = humhubUsers.get(postUser.account.username)
if (!humhubUser) {
// fallback for legacy users
humhubUser = humhubUsers.get(user.gradidoID)
}
const humHubClient = HumHubClient.getInstance() const humHubClient = HumHubClient.getInstance()
if (!humHubClient) { if (!humHubClient) {
throw new LogError('Error creating humhub client') throw new LogError('Error creating humhub client')

View File

@ -7,6 +7,7 @@ export const INALIENABLE_RIGHTS = [
RIGHTS.SEND_RESET_PASSWORD_EMAIL, RIGHTS.SEND_RESET_PASSWORD_EMAIL,
RIGHTS.SET_PASSWORD, RIGHTS.SET_PASSWORD,
RIGHTS.QUERY_TRANSACTION_LINK, RIGHTS.QUERY_TRANSACTION_LINK,
RIGHTS.QUERY_REDEEM_JWT,
RIGHTS.QUERY_OPT_IN, RIGHTS.QUERY_OPT_IN,
RIGHTS.CHECK_USERNAME, RIGHTS.CHECK_USERNAME,
RIGHTS.PROJECT_BRANDING_BANNER, RIGHTS.PROJECT_BRANDING_BANNER,

View File

@ -6,6 +6,7 @@ export enum RIGHTS {
SEND_RESET_PASSWORD_EMAIL = 'SEND_RESET_PASSWORD_EMAIL', SEND_RESET_PASSWORD_EMAIL = 'SEND_RESET_PASSWORD_EMAIL',
SET_PASSWORD = 'SET_PASSWORD', SET_PASSWORD = 'SET_PASSWORD',
QUERY_TRANSACTION_LINK = 'QUERY_TRANSACTION_LINK', QUERY_TRANSACTION_LINK = 'QUERY_TRANSACTION_LINK',
QUERY_REDEEM_JWT = 'QUERY_REDEEM_JWT',
QUERY_OPT_IN = 'QUERY_OPT_IN', QUERY_OPT_IN = 'QUERY_OPT_IN',
CHECK_USERNAME = 'CHECK_USERNAME', CHECK_USERNAME = 'CHECK_USERNAME',
PROJECT_BRANDING_BANNER = 'PROJECT_BRANDING_BANNER', PROJECT_BRANDING_BANNER = 'PROJECT_BRANDING_BANNER',
@ -24,6 +25,7 @@ export enum RIGHTS {
CREATE_TRANSACTION_LINK = 'CREATE_TRANSACTION_LINK', CREATE_TRANSACTION_LINK = 'CREATE_TRANSACTION_LINK',
DELETE_TRANSACTION_LINK = 'DELETE_TRANSACTION_LINK', DELETE_TRANSACTION_LINK = 'DELETE_TRANSACTION_LINK',
REDEEM_TRANSACTION_LINK = 'REDEEM_TRANSACTION_LINK', REDEEM_TRANSACTION_LINK = 'REDEEM_TRANSACTION_LINK',
DISBURSE_TRANSACTION_LINK = 'DISBURSE_TRANSACTION_LINK',
LIST_TRANSACTION_LINKS = 'LIST_TRANSACTION_LINKS', LIST_TRANSACTION_LINKS = 'LIST_TRANSACTION_LINKS',
GDT_BALANCE = 'GDT_BALANCE', GDT_BALANCE = 'GDT_BALANCE',
CREATE_CONTRIBUTION = 'CREATE_CONTRIBUTION', CREATE_CONTRIBUTION = 'CREATE_CONTRIBUTION',

View File

@ -15,6 +15,7 @@ export const USER_RIGHTS = [
RIGHTS.CREATE_TRANSACTION_LINK, RIGHTS.CREATE_TRANSACTION_LINK,
RIGHTS.DELETE_TRANSACTION_LINK, RIGHTS.DELETE_TRANSACTION_LINK,
RIGHTS.REDEEM_TRANSACTION_LINK, RIGHTS.REDEEM_TRANSACTION_LINK,
RIGHTS.DISBURSE_TRANSACTION_LINK,
RIGHTS.LIST_TRANSACTION_LINKS, RIGHTS.LIST_TRANSACTION_LINKS,
RIGHTS.GDT_BALANCE, RIGHTS.GDT_BALANCE,
RIGHTS.CREATE_CONTRIBUTION, RIGHTS.CREATE_CONTRIBUTION,

View File

@ -0,0 +1,70 @@
import { createPrivateKey, sign } from 'node:crypto'
import { JWTPayload, SignJWT, decodeJwt, jwtVerify } from 'jose'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import { JwtPayloadType } from './payloadtypes/JwtPayloadType'
export const verify = async (token: string, signkey: string): Promise<JwtPayloadType | null> => {
if (!token) {
throw new LogError('401 Unauthorized')
}
logger.info('JWT.verify... token, signkey=', token, signkey)
try {
/*
const { KeyObject } = await import('node:crypto')
const cryptoKey = await crypto.subtle.importKey('raw', signkey, { name: 'RS256' }, false, [
'sign',
])
const keyObject = KeyObject.from(cryptoKey)
logger.info('JWT.verify... keyObject=', keyObject)
logger.info('JWT.verify... keyObject.asymmetricKeyDetails=', keyObject.asymmetricKeyDetails)
logger.info('JWT.verify... keyObject.asymmetricKeyType=', keyObject.asymmetricKeyType)
logger.info('JWT.verify... keyObject.asymmetricKeySize=', keyObject.asymmetricKeySize)
*/
const secret = new TextEncoder().encode(signkey)
const { payload } = await jwtVerify(token, secret, {
issuer: 'urn:gradido:issuer',
audience: 'urn:gradido:audience',
})
logger.info('JWT.verify after jwtVerify... payload=', payload)
return payload as JwtPayloadType
} catch (err) {
logger.error('JWT.verify after jwtVerify... error=', err)
return null
}
}
export const encode = async (payload: JwtPayloadType, signkey: string): Promise<string> => {
logger.info('JWT.encode... payload=', payload)
logger.info('JWT.encode... signkey=', signkey)
try {
const secret = new TextEncoder().encode(signkey)
const token = await new SignJWT({ payload, 'urn:gradido:claim': true })
.setProtectedHeader({
alg: 'HS256',
})
.setIssuedAt()
.setIssuer('urn:gradido:issuer')
.setAudience('urn:gradido:audience')
.setExpirationTime(payload.expiration)
.sign(secret)
return token
} catch (e) {
logger.error('Failed to sign JWT:', e)
throw e
}
}
export const verifyJwtType = async (token: string, signkey: string): Promise<string> => {
const payload = await verify(token, signkey)
return payload ? payload.tokentype : 'unknown token type'
}
export const decode = (token: string): JwtPayloadType => {
const { payload } = decodeJwt(token)
return payload as JwtPayloadType
}

View File

@ -0,0 +1,48 @@
// import { JWTPayload } from 'jose'
import { JwtPayloadType } from './JwtPayloadType'
export class DisburseJwtPayloadType extends JwtPayloadType {
static DISBURSE_ACTIVATION_TYPE = 'disburse-activation'
sendercommunityuuid: string
sendergradidoid: string
recipientcommunityuuid: string
recipientcommunityname: string
recipientgradidoid: string
recipientfirstname: string
code: string
amount: string
memo: string
validuntil: string
recipientalias: string
constructor(
senderCommunityUuid: string,
senderGradidoId: string,
recipientCommunityUuid: string,
recipientCommunityName: string,
recipientGradidoId: string,
recipientFirstName: string,
code: string,
amount: string,
memo: string,
validUntil: string,
recipientAlias: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super()
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = DisburseJwtPayloadType.DISBURSE_ACTIVATION_TYPE
this.sendercommunityuuid = senderCommunityUuid
this.sendergradidoid = senderGradidoId
this.recipientcommunityuuid = recipientCommunityUuid
this.recipientcommunityname = recipientCommunityName
this.recipientgradidoid = recipientGradidoId
this.recipientfirstname = recipientFirstName
this.code = code
this.amount = amount
this.memo = memo
this.validuntil = validUntil
this.recipientalias = recipientAlias
}
}

View File

@ -0,0 +1,21 @@
import { JWTPayload } from 'jose'
import { CONFIG } from '@/config'
export class JwtPayloadType implements JWTPayload {
iat?: number | undefined
exp?: number | undefined
nbf?: number | undefined
jti?: string | undefined
aud?: string | string[] | undefined
sub?: string | undefined
iss?: string | undefined;
[propName: string]: unknown
tokentype: string
expiration: string // in minutes (format: 10m for ten minutes)
constructor() {
this.tokentype = 'unknown jwt type'
this.expiration = CONFIG.REDEEM_JWT_TOKEN_EXPIRATION || '10m'
}
}

View File

@ -0,0 +1,36 @@
// import { JWTPayload } from 'jose'
import { JwtPayloadType } from './JwtPayloadType'
export class RedeemJwtPayloadType extends JwtPayloadType {
static REDEEM_ACTIVATION_TYPE = 'redeem-activation'
sendercommunityuuid: string
sendergradidoid: string
sendername: string // alias or firstname
redeemcode: string
amount: string
memo: string
validuntil: string
constructor(
senderCom: string,
senderUser: string,
sendername: string,
code: string,
amount: string,
memo: string,
validUntil: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super()
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = RedeemJwtPayloadType.REDEEM_ACTIVATION_TYPE
this.sendercommunityuuid = senderCom
this.sendergradidoid = senderUser
this.sendername = sendername
this.redeemcode = code
this.amount = amount
this.memo = memo
this.validuntil = validUntil
}
}

View File

@ -1,6 +1,5 @@
// ATTENTION: DO NOT PUT ANY SECRETS IN HERE (or the .env) // ATTENTION: DO NOT PUT ANY SECRETS IN HERE (or the .env)
// eslint-disable-next-line import/no-unresolved
import { validate } from 'config-schema' import { validate } from 'config-schema'
import { latestDbVersion } from 'database' import { latestDbVersion } from 'database'
import { Decimal } from 'decimal.js-light' import { Decimal } from 'decimal.js-light'
@ -26,6 +25,7 @@ const server = {
PORT: process.env.PORT ?? 4000, PORT: process.env.PORT ?? 4000,
JWT_SECRET: process.env.JWT_SECRET ?? 'secret123', JWT_SECRET: process.env.JWT_SECRET ?? 'secret123',
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN ?? '10m', JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN ?? '10m',
REDEEM_JWT_TOKEN_EXPIRATION: process.env.REDEEM_JWT_TOKEN_EXPIRATION ?? '10m',
GRAPHIQL: process.env.GRAPHIQL === 'true' || false, GRAPHIQL: process.env.GRAPHIQL === 'true' || false,
GDT_ACTIVE: process.env.GDT_ACTIVE === 'true' || false, GDT_ACTIVE: process.env.GDT_ACTIVE === 'true' || false,
GDT_API_URL: process.env.GDT_API_URL ?? 'https://gdt.gradido.net', GDT_API_URL: process.env.GDT_API_URL ?? 'https://gdt.gradido.net',
@ -35,8 +35,14 @@ const server = {
} }
const database = { const database = {
DB_CONNECT_RETRY_COUNT: process.env.DB_CONNECT_RETRY_COUNT
? Number.parseInt(process.env.DB_CONNECT_RETRY_COUNT)
: 15,
DB_CONNECT_RETRY_DELAY_MS: process.env.DB_CONNECT_RETRY_DELAY_MS
? Number.parseInt(process.env.DB_CONNECT_RETRY_DELAY_MS)
: 500,
DB_HOST: process.env.DB_HOST ?? 'localhost', DB_HOST: process.env.DB_HOST ?? 'localhost',
DB_PORT: process.env.DB_PORT ? parseInt(process.env.DB_PORT) : 3306, DB_PORT: process.env.DB_PORT ? Number.parseInt(process.env.DB_PORT) : 3306,
DB_USER: process.env.DB_USER ?? 'root', DB_USER: process.env.DB_USER ?? 'root',
DB_PASSWORD: process.env.DB_PASSWORD ?? '', DB_PASSWORD: process.env.DB_PASSWORD ?? '',
DB_DATABASE: process.env.DB_DATABASE ?? 'gradido_community', DB_DATABASE: process.env.DB_DATABASE ?? 'gradido_community',

View File

@ -3,6 +3,8 @@ import {
COMMUNITY_NAME, COMMUNITY_NAME,
COMMUNITY_SUPPORT_MAIL, COMMUNITY_SUPPORT_MAIL,
COMMUNITY_URL, COMMUNITY_URL,
DB_CONNECT_RETRY_COUNT,
DB_CONNECT_RETRY_DELAY_MS,
DB_DATABASE, DB_DATABASE,
DB_HOST, DB_HOST,
DB_PASSWORD, DB_PASSWORD,
@ -38,6 +40,8 @@ export const schema = Joi.object({
DB_USER, DB_USER,
DB_VERSION, DB_VERSION,
DB_DATABASE, DB_DATABASE,
DB_CONNECT_RETRY_COUNT,
DB_CONNECT_RETRY_DELAY_MS,
DECAY_START_TIME, DECAY_START_TIME,
GDT_API_URL, GDT_API_URL,
GDT_ACTIVE, GDT_ACTIVE,
@ -363,5 +367,20 @@ export const schema = Joi.object({
.required() .required()
.description('Time for JWT token to expire, auto logout'), .description('Time for JWT token to expire, auto logout'),
REDEEM_JWT_TOKEN_EXPIRATION: Joi.alternatives()
.try(
Joi.string()
.pattern(/^\d+[smhdw]$/)
.description(
'Expiration time for x-community redeem JWT token, in format like "10m", "1h", "1d"',
)
.default('10m'),
Joi.number()
.positive()
.description('Expiration time for x-community redeem JWT token in minutes'),
)
.required()
.description('Time for x-community redeem JWT token to expire'),
WEBHOOK_ELOPAGE_SECRET: Joi.string().description("isn't really used any more").optional(), WEBHOOK_ELOPAGE_SECRET: Joi.string().description("isn't really used any more").optional(),
}) })

View File

@ -78,6 +78,7 @@ export class FederationClient {
) )
return data.getPublicCommunityInfo return data.getPublicCommunityInfo
} catch (err) { } catch (err) {
logger.warn(' err', err)
const errorString = JSON.stringify(err) const errorString = JSON.stringify(err)
logger.warn('Federation: getPublicCommunityInfo failed for endpoint', { logger.warn('Federation: getPublicCommunityInfo failed for endpoint', {
endpoint: this.endpoint, endpoint: this.endpoint,

View File

@ -0,0 +1,55 @@
import { Decimal } from 'decimal.js-light'
import { Field, ObjectType } from 'type-graphql'
import { RedeemJwtPayloadType } from '@/auth/jwt/payloadtypes/RedeemJwtPayloadType'
import { Community } from './Community'
import { User } from './User'
@ObjectType()
export class RedeemJwtLink {
constructor(
redeemJwtPayload: RedeemJwtPayloadType,
senderCommunity: Community,
senderUser: User,
recipientCommunity: Community,
recipientUser?: User,
) {
this.senderCommunity = senderCommunity
this.recipientCommunity = recipientCommunity
this.senderUser = senderUser
if (recipientUser !== undefined) {
this.recipientUser = recipientUser
} else {
this.recipientUser = null
}
this.amount = new Decimal(redeemJwtPayload.amount)
this.memo = redeemJwtPayload.memo
this.code = redeemJwtPayload.redeemcode
this.validUntil = new Date(redeemJwtPayload.validuntil)
}
@Field(() => Community)
senderCommunity: Community
@Field(() => User)
senderUser: User
@Field(() => Community)
recipientCommunity: Community
@Field(() => User, { nullable: true })
recipientUser: User | null
@Field(() => Decimal)
amount: Decimal
@Field(() => String)
memo: string
@Field(() => String)
code: string
@Field(() => Date)
validUntil: Date
}

View File

@ -1,33 +1,48 @@
import { TransactionLink as dbTransactionLink } from 'database' import { Community as DbCommunity, TransactionLink as DbTransactionLink } from 'database'
import { Decimal } from 'decimal.js-light' import { Decimal } from 'decimal.js-light'
import { Field, Int, ObjectType } from 'type-graphql' import { Field, Int, ObjectType } from 'type-graphql'
import { CONFIG } from '@/config' import { CONFIG } from '@/config'
import { Community } from './Community'
import { User } from './User' import { User } from './User'
@ObjectType() @ObjectType()
export class TransactionLink { export class TransactionLink {
constructor(transactionLink: dbTransactionLink, user: User, redeemedBy: User | null = null) { constructor(
this.id = transactionLink.id dbTransactionLink?: DbTransactionLink,
this.user = user user?: User,
this.amount = transactionLink.amount redeemedBy?: User,
this.holdAvailableAmount = transactionLink.holdAvailableAmount dbCommunities?: DbCommunity[],
this.memo = transactionLink.memo ) {
this.code = transactionLink.code if (dbTransactionLink !== undefined) {
this.createdAt = transactionLink.createdAt this.id = dbTransactionLink.id
this.validUntil = transactionLink.validUntil this.amount = dbTransactionLink.amount
this.deletedAt = transactionLink.deletedAt this.holdAvailableAmount = dbTransactionLink.holdAvailableAmount
this.redeemedAt = transactionLink.redeemedAt this.memo = dbTransactionLink.memo
this.redeemedBy = redeemedBy this.code = dbTransactionLink.code
this.link = CONFIG.COMMUNITY_REDEEM_URL + this.code this.link = CONFIG.COMMUNITY_REDEEM_URL + this.code
this.createdAt = dbTransactionLink.createdAt
this.validUntil = dbTransactionLink.validUntil
this.deletedAt = dbTransactionLink.deletedAt
this.redeemedAt = dbTransactionLink.redeemedAt
}
if (user !== undefined) {
this.senderUser = user
}
if (redeemedBy !== undefined) {
this.redeemedBy = redeemedBy
}
if (dbCommunities !== undefined) {
this.communities = dbCommunities.map((dbCom: DbCommunity) => new Community(dbCom))
}
} }
@Field(() => Int) @Field(() => Int)
id: number id: number
@Field(() => User) @Field(() => User)
user: User senderUser: User
@Field(() => Decimal) @Field(() => Decimal)
amount: Decimal amount: Decimal
@ -58,6 +73,12 @@ export class TransactionLink {
@Field(() => String) @Field(() => String)
link: string link: string
@Field(() => String)
communityName: string
@Field(() => [Community])
communities: Community[]
} }
@ObjectType() @ObjectType()

View File

@ -1,4 +1,4 @@
import { User as dbUser } from 'database' import { User as DbUser } from 'database'
import { Field, Int, ObjectType } from 'type-graphql' import { Field, Int, ObjectType } from 'type-graphql'
import { Point } from 'typeorm' import { Point } from 'typeorm'
@ -14,43 +14,43 @@ import { UserContact } from './UserContact'
@ObjectType() @ObjectType()
export class User { export class User {
constructor(user: dbUser | null) { constructor(dbUser: DbUser | null) {
if (user) { if (dbUser) {
this.id = user.id this.id = dbUser.id
this.foreign = user.foreign this.foreign = dbUser.foreign
this.communityUuid = user.communityUuid this.communityUuid = dbUser.communityUuid
if (user.community) { if (dbUser.community) {
this.communityName = user.community.name this.communityName = dbUser.community.name
} }
this.gradidoID = user.gradidoID this.gradidoID = dbUser.gradidoID
this.alias = user.alias this.alias = dbUser.alias
const publishNameLogic = new PublishNameLogic(user) const publishNameLogic = new PublishNameLogic(dbUser)
const publishNameType = user.humhubPublishName as PublishNameType const publishNameType = dbUser.humhubPublishName as PublishNameType
this.publicName = publishNameLogic.getPublicName(publishNameType) this.publicName = publishNameLogic.getPublicName(publishNameType)
this.userIdentifier = publishNameLogic.getUserIdentifier(publishNameType) this.userIdentifier = publishNameLogic.getUserIdentifier(publishNameType)
if (user.emailContact) { if (dbUser.emailContact) {
this.emailChecked = user.emailContact.emailChecked this.emailChecked = dbUser.emailContact.emailChecked
this.emailContact = new UserContact(user.emailContact) this.emailContact = new UserContact(dbUser.emailContact)
} }
this.firstName = user.firstName this.firstName = dbUser.firstName
this.lastName = user.lastName this.lastName = dbUser.lastName
this.deletedAt = user.deletedAt this.deletedAt = dbUser.deletedAt
this.createdAt = user.createdAt this.createdAt = dbUser.createdAt
this.language = user.language this.language = dbUser.language
this.publisherId = user.publisherId this.publisherId = dbUser.publisherId
this.roles = user.userRoles?.map((userRole) => userRole.role) ?? [] this.roles = dbUser.userRoles?.map((userRole) => userRole.role) ?? []
this.klickTipp = null this.klickTipp = null
this.hasElopage = null this.hasElopage = null
this.hideAmountGDD = user.hideAmountGDD this.hideAmountGDD = dbUser.hideAmountGDD
this.hideAmountGDT = user.hideAmountGDT this.hideAmountGDT = dbUser.hideAmountGDT
this.humhubAllowed = user.humhubAllowed this.humhubAllowed = dbUser.humhubAllowed
this.gmsAllowed = user.gmsAllowed this.gmsAllowed = dbUser.gmsAllowed
this.gmsPublishName = user.gmsPublishName this.gmsPublishName = dbUser.gmsPublishName
this.humhubPublishName = user.humhubPublishName this.humhubPublishName = dbUser.humhubPublishName
this.gmsPublishLocation = user.gmsPublishLocation this.gmsPublishLocation = dbUser.gmsPublishLocation
this.userLocation = user.location ? Point2Location(user.location as Point) : null this.userLocation = dbUser.location ? Point2Location(dbUser.location as Point) : null
} }
} }

View File

@ -1,5 +1,19 @@
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import { Paginated } from '@arg/Paginated'
import { TransactionLinkArgs } from '@arg/TransactionLinkArgs'
import { TransactionLinkFilters } from '@arg/TransactionLinkFilters'
import { ContributionCycleType } from '@enum/ContributionCycleType'
import { ContributionStatus } from '@enum/ContributionStatus'
import { ContributionType } from '@enum/ContributionType'
import { TransactionTypeId } from '@enum/TransactionTypeId'
import { Community } from '@model/Community'
import { ContributionLink } from '@model/ContributionLink'
import { Decay } from '@model/Decay'
import { RedeemJwtLink } from '@model/RedeemJwtLink'
import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink'
import { User } from '@model/User'
import { QueryLinkResult } from '@union/QueryLinkResult'
import { import {
Contribution as DbContribution, Contribution as DbContribution,
ContributionLink as DbContributionLink, ContributionLink as DbContributionLink,
@ -11,20 +25,9 @@ import { Decimal } from 'decimal.js-light'
import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql' import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
import { getConnection } from 'typeorm' import { getConnection } from 'typeorm'
import { Paginated } from '@arg/Paginated'
import { TransactionLinkArgs } from '@arg/TransactionLinkArgs'
import { TransactionLinkFilters } from '@arg/TransactionLinkFilters'
import { ContributionCycleType } from '@enum/ContributionCycleType'
import { ContributionStatus } from '@enum/ContributionStatus'
import { ContributionType } from '@enum/ContributionType'
import { TransactionTypeId } from '@enum/TransactionTypeId'
import { ContributionLink } from '@model/ContributionLink'
import { Decay } from '@model/Decay'
import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink'
import { User } from '@model/User'
import { QueryLinkResult } from '@union/QueryLinkResult'
import { RIGHTS } from '@/auth/RIGHTS' import { RIGHTS } from '@/auth/RIGHTS'
import { decode, encode, verify } from '@/auth/jwt/JWT'
import { RedeemJwtPayloadType } from '@/auth/jwt/payloadtypes/RedeemJwtPayloadType'
import { import {
EVENT_CONTRIBUTION_LINK_REDEEM, EVENT_CONTRIBUTION_LINK_REDEEM,
EVENT_TRANSACTION_LINK_CREATE, EVENT_TRANSACTION_LINK_CREATE,
@ -40,7 +43,13 @@ import { calculateDecay } from '@/util/decay'
import { fullName } from '@/util/utilities' import { fullName } from '@/util/utilities'
import { calculateBalance } from '@/util/validate' import { calculateBalance } from '@/util/validate'
import { DisburseJwtPayloadType } from '@/auth/jwt/payloadtypes/DisburseJwtPayloadType'
import { executeTransaction } from './TransactionResolver' import { executeTransaction } from './TransactionResolver'
import {
getAuthenticatedCommunities,
getCommunityByUuid,
getHomeCommunity,
} from './util/communities'
import { getUserCreation, validateContribution } from './util/creations' import { getUserCreation, validateContribution } from './util/creations'
import { getLastTransaction } from './util/getLastTransaction' import { getLastTransaction } from './util/getLastTransaction'
import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector' import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector'
@ -138,6 +147,7 @@ export class TransactionLinkResolver {
@Authorized([RIGHTS.QUERY_TRANSACTION_LINK]) @Authorized([RIGHTS.QUERY_TRANSACTION_LINK])
@Query(() => QueryLinkResult) @Query(() => QueryLinkResult)
async queryTransactionLink(@Arg('code') code: string): Promise<typeof QueryLinkResult> { async queryTransactionLink(@Arg('code') code: string): Promise<typeof QueryLinkResult> {
logger.debug('TransactionLinkResolver.queryTransactionLink... code=', code)
if (code.match(/^CL-/)) { if (code.match(/^CL-/)) {
const contributionLink = await DbContributionLink.findOneOrFail({ const contributionLink = await DbContributionLink.findOneOrFail({
where: { code: code.replace('CL-', '') }, where: { code: code.replace('CL-', '') },
@ -145,18 +155,36 @@ export class TransactionLinkResolver {
}) })
return new ContributionLink(contributionLink) return new ContributionLink(contributionLink)
} else { } else {
const transactionLink = await DbTransactionLink.findOneOrFail({ let txLinkFound = false
where: { code }, let dbTransactionLink!: DbTransactionLink
withDeleted: true, try {
}) dbTransactionLink = await DbTransactionLink.findOneOrFail({
const user = await DbUser.findOneOrFail({ where: { id: transactionLink.userId } }) where: { code },
let redeemedBy: User | null = null withDeleted: true,
if (transactionLink?.redeemedBy) { })
redeemedBy = new User( txLinkFound = true
await DbUser.findOneOrFail({ where: { id: transactionLink.redeemedBy } }), } catch (_err) {
) txLinkFound = false
}
// normal redeem code
if (txLinkFound) {
logger.debug(
'TransactionLinkResolver.queryTransactionLink... normal redeem code found=',
txLinkFound,
)
const user = await DbUser.findOneOrFail({ where: { id: dbTransactionLink.userId } })
let redeemedBy
if (dbTransactionLink.redeemedBy) {
redeemedBy = new User(
await DbUser.findOneOrFail({ where: { id: dbTransactionLink.redeemedBy } }),
)
}
const communities = await getAuthenticatedCommunities()
return new TransactionLink(dbTransactionLink, new User(user), redeemedBy, communities)
} else {
// redeem jwt-token
return await this.queryRedeemJwtLink(code)
} }
return new TransactionLink(transactionLink, new User(user), redeemedBy)
} }
} }
@ -169,7 +197,6 @@ export class TransactionLinkResolver {
const clientTimezoneOffset = getClientTimezoneOffset(context) const clientTimezoneOffset = getClientTimezoneOffset(context)
// const homeCom = await DbCommunity.findOneOrFail({ where: { foreign: false } }) // const homeCom = await DbCommunity.findOneOrFail({ where: { foreign: false } })
const user = getUser(context) const user = getUser(context)
if (code.match(/^CL-/)) { if (code.match(/^CL-/)) {
// acquire lock // acquire lock
const releaseLock = await TRANSACTIONS_LOCK.acquire() const releaseLock = await TRANSACTIONS_LOCK.acquire()
@ -366,6 +393,104 @@ export class TransactionLinkResolver {
} }
} }
@Authorized([RIGHTS.QUERY_REDEEM_JWT])
@Mutation(() => String)
async createRedeemJwt(
@Arg('gradidoId') gradidoId: string,
@Arg('senderCommunityUuid') senderCommunityUuid: string,
@Arg('senderCommunityName') senderCommunityName: string,
@Arg('recipientCommunityUuid') recipientCommunityUuid: string,
@Arg('code') code: string,
@Arg('amount') amount: string,
@Arg('memo') memo: string,
@Arg('firstName', { nullable: true }) firstName?: string,
@Arg('alias', { nullable: true }) alias?: string,
@Arg('validUntil', { nullable: true }) validUntil?: string,
): Promise<string> {
logger.debug('TransactionLinkResolver.queryRedeemJwt... args=', {
gradidoId,
senderCommunityUuid,
senderCommunityName,
recipientCommunityUuid,
code,
amount,
memo,
firstName,
alias,
validUntil,
})
const redeemJwtPayloadType = new RedeemJwtPayloadType(
senderCommunityUuid,
gradidoId,
alias ?? firstName ?? '',
code,
amount,
memo,
validUntil ?? '',
)
// TODO:encode/sign the jwt normally with the private key of the sender/home community, but interims with uuid
const homeCom = await getHomeCommunity()
if (!homeCom.communityUuid) {
throw new LogError('Home community UUID is not set')
}
const redeemJwt = await encode(redeemJwtPayloadType, homeCom.communityUuid)
// TODO: encrypt the payload with the public key of the target community
return redeemJwt
}
@Authorized([RIGHTS.DISBURSE_TRANSACTION_LINK])
@Mutation(() => Boolean)
async disburseTransactionLink(
@Ctx() _context: Context,
@Arg('senderCommunityUuid') senderCommunityUuid: string,
@Arg('senderGradidoId') senderGradidoId: string,
@Arg('recipientCommunityUuid') recipientCommunityUuid: string,
@Arg('recipientCommunityName') recipientCommunityName: string,
@Arg('recipientGradidoId') recipientGradidoId: string,
@Arg('recipientFirstName') recipientFirstName: string,
@Arg('code') code: string,
@Arg('amount') amount: string,
@Arg('memo') memo: string,
@Arg('validUntil', { nullable: true }) validUntil?: string,
@Arg('recipientAlias', { nullable: true }) recipientAlias?: string,
): Promise<boolean> {
logger.debug('TransactionLinkResolver.disburseTransactionLink... args=', {
senderGradidoId,
senderCommunityUuid,
recipientCommunityUuid,
recipientCommunityName,
recipientGradidoId,
recipientFirstName,
code,
amount,
memo,
validUntil,
recipientAlias,
})
const disburseJwt = await this.createDisburseJwt(
senderCommunityUuid,
senderGradidoId,
recipientCommunityUuid,
recipientCommunityName,
recipientGradidoId,
recipientFirstName,
code,
amount,
memo,
validUntil ?? '',
recipientAlias ?? '',
)
try {
logger.debug('TransactionLinkResolver.disburseTransactionLink... disburseJwt=', disburseJwt)
// now send the disburseJwt to the sender community to invoke a x-community-tx to disbures the redeemLink
// await sendDisburseJwtToSenderCommunity(context, disburseJwt)
} catch (e) {
throw new LogError('Disburse JWT was not sent successfully', e)
}
return true
}
@Authorized([RIGHTS.LIST_TRANSACTION_LINKS]) @Authorized([RIGHTS.LIST_TRANSACTION_LINKS])
@Query(() => TransactionLinkResult) @Query(() => TransactionLinkResult)
async listTransactionLinks( async listTransactionLinks(
@ -400,4 +525,163 @@ export class TransactionLinkResolver {
} }
return transactionLinkList(paginated, filters, user) return transactionLinkList(paginated, filters, user)
} }
async queryRedeemJwtLink(code: string): Promise<RedeemJwtLink> {
logger.debug('TransactionLinkResolver.queryRedeemJwtLink... redeem jwt-token found')
// decode token first to get the senderCommunityUuid as input for verify token
const decodedPayload = decode(code)
logger.debug('TransactionLinkResolver.queryRedeemJwtLink... decodedPayload=', decodedPayload)
if (
decodedPayload != null &&
decodedPayload.tokentype === RedeemJwtPayloadType.REDEEM_ACTIVATION_TYPE
) {
const redeemJwtPayload = new RedeemJwtPayloadType(
decodedPayload.sendercommunityuuid as string,
decodedPayload.sendergradidoid as string,
decodedPayload.sendername as string,
decodedPayload.redeemcode as string,
decodedPayload.amount as string,
decodedPayload.memo as string,
decodedPayload.validuntil as string,
)
logger.debug(
'TransactionLinkResolver.queryRedeemJwtLink... redeemJwtPayload=',
redeemJwtPayload,
)
const senderCom = await getCommunityByUuid(redeemJwtPayload.sendercommunityuuid)
if (!senderCom) {
throw new LogError('Sender community not found:', redeemJwtPayload.sendercommunityuuid)
}
logger.debug('TransactionLinkResolver.queryRedeemJwtLink... senderCom=', senderCom)
if (!senderCom.communityUuid) {
throw new LogError('Sender community UUID is not set')
}
// now with the sender community UUID the jwt token can be verified
const verifiedJwtPayload = await verify(code, senderCom.communityUuid)
logger.debug(
'TransactionLinkResolver.queryRedeemJwtLink... nach verify verifiedJwtPayload=',
verifiedJwtPayload,
)
let verifiedRedeemJwtPayload: RedeemJwtPayloadType | null = null
if (verifiedJwtPayload !== null) {
if (verifiedJwtPayload.exp !== undefined) {
const expDate = new Date(verifiedJwtPayload.exp * 1000)
logger.debug(
'TransactionLinkResolver.queryRedeemJwtLink... expDate, exp =',
expDate,
verifiedJwtPayload.exp,
)
if (expDate < new Date()) {
throw new LogError('Redeem JWT-Token expired! jwtPayload.exp=', expDate)
}
}
if (verifiedJwtPayload.tokentype === RedeemJwtPayloadType.REDEEM_ACTIVATION_TYPE) {
logger.debug(
'TransactionLinkResolver.queryRedeemJwtLink... verifiedJwtPayload.tokentype=',
verifiedJwtPayload.tokentype,
)
verifiedRedeemJwtPayload = new RedeemJwtPayloadType(
verifiedJwtPayload.sendercommunityuuid as string,
verifiedJwtPayload.sendergradidoid as string,
verifiedJwtPayload.sendername as string,
verifiedJwtPayload.redeemcode as string,
verifiedJwtPayload.amount as string,
verifiedJwtPayload.memo as string,
verifiedJwtPayload.validuntil as string,
)
logger.debug(
'TransactionLinkResolver.queryRedeemJwtLink... nach verify verifiedRedeemJwtPayload=',
verifiedRedeemJwtPayload,
)
}
}
if (verifiedRedeemJwtPayload === null) {
logger.debug(
'TransactionLinkResolver.queryRedeemJwtLink... verifiedRedeemJwtPayload===null',
)
verifiedRedeemJwtPayload = new RedeemJwtPayloadType(
decodedPayload.sendercommunityuuid as string,
decodedPayload.sendergradidoid as string,
decodedPayload.sendername as string,
decodedPayload.redeemcode as string,
decodedPayload.amount as string,
decodedPayload.memo as string,
decodedPayload.validuntil as string,
)
} else {
// TODO: as long as the verification fails, fallback to simply decoded payload
verifiedRedeemJwtPayload = redeemJwtPayload
logger.debug(
'TransactionLinkResolver.queryRedeemJwtLink... fallback to decode verifiedRedeemJwtPayload=',
verifiedRedeemJwtPayload,
)
}
const homeCommunity = await getHomeCommunity()
const recipientCommunity = new Community(homeCommunity)
const senderCommunity = new Community(senderCom)
const senderUser = new User(null)
senderUser.gradidoID = verifiedRedeemJwtPayload.sendergradidoid
senderUser.firstName = verifiedRedeemJwtPayload.sendername
const redeemJwtLink = new RedeemJwtLink(
verifiedRedeemJwtPayload,
senderCommunity,
senderUser,
recipientCommunity,
)
logger.debug('TransactionLinkResolver.queryRedeemJwtLink... redeemJwtLink=', redeemJwtLink)
return redeemJwtLink
} else {
throw new LogError(
'Redeem with wrong type of JWT-Token or expired! decodedPayload=',
decodedPayload,
)
}
}
async createDisburseJwt(
senderCommunityUuid: string,
senderGradidoId: string,
recipientCommunityUuid: string,
recipientCommunityName: string,
recipientGradidoId: string,
recipientFirstName: string,
code: string,
amount: string,
memo: string,
validUntil: string,
recipientAlias: string,
): Promise<string> {
logger.debug('TransactionLinkResolver.createDisburseJwt... args=', {
senderCommunityUuid,
senderGradidoId,
recipientCommunityUuid,
recipientCommunityName,
recipientGradidoId,
recipientFirstName,
code,
amount,
memo,
validUntil,
recipientAlias,
})
const disburseJwtPayloadType = new DisburseJwtPayloadType(
senderCommunityUuid,
senderGradidoId,
recipientCommunityUuid,
recipientCommunityName,
recipientGradidoId,
recipientFirstName,
code,
amount,
memo,
validUntil,
recipientAlias,
)
// TODO:encode/sign the jwt normally with the private key of the recipient community, but interims with uuid
const disburseJwt = await encode(disburseJwtPayloadType, recipientCommunityUuid)
logger.debug('TransactionLinkResolver.createDisburseJwt... disburseJwt=', disburseJwt)
// TODO: encrypt the payload with the public key of the target/sender community
return disburseJwt
}
} }

View File

@ -1,5 +1,5 @@
import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity } from 'database' import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity } from 'database'
import { FindOneOptions } from 'typeorm' import { FindOneOptions, IsNull, Not } from 'typeorm'
import { Paginated } from '@arg/Paginated' import { Paginated } from '@arg/Paginated'
@ -86,6 +86,16 @@ export async function getCommunityByUuid(communityUuid: string): Promise<DbCommu
}) })
} }
export async function getAuthenticatedCommunities(): Promise<DbCommunity[]> {
const dbCommunities: DbCommunity[] = await DbCommunity.find({
where: { communityUuid: Not(IsNull()) }, //, authenticatedAt: Not(IsNull()) },
order: {
name: 'ASC',
},
})
return dbCommunities
}
export async function getCommunityByIdentifier( export async function getCommunityByIdentifier(
communityIdentifier: string, communityIdentifier: string,
): Promise<DbCommunity | null> { ): Promise<DbCommunity | null> {

View File

@ -4,7 +4,9 @@ import { HumHubClient } from '@/apis/humhub/HumHubClient'
import { GetUser } from '@/apis/humhub/model/GetUser' import { GetUser } from '@/apis/humhub/model/GetUser'
import { PostUser } from '@/apis/humhub/model/PostUser' import { PostUser } from '@/apis/humhub/model/PostUser'
import { ExecutedHumhubAction, syncUser } from '@/apis/humhub/syncUser' import { ExecutedHumhubAction, syncUser } from '@/apis/humhub/syncUser'
import { PublishNameLogic } from '@/data/PublishName.logic'
import { UpdateUserInfosArgs } from '@/graphql/arg/UpdateUserInfosArgs' import { UpdateUserInfosArgs } from '@/graphql/arg/UpdateUserInfosArgs'
import { PublishNameType } from '@/graphql/enum/PublishNameType'
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
/** /**
@ -44,7 +46,9 @@ export async function syncHumhub(
} }
const humhubUsers = new Map<string, GetUser>() const humhubUsers = new Map<string, GetUser>()
if (humhubUser) { if (humhubUser) {
humhubUsers.set(humhubUser.account.username, humhubUser) const publishNameLogic = new PublishNameLogic(user)
const username = publishNameLogic.getUserIdentifier(user.humhubPublishName as PublishNameType)
humhubUsers.set(username, humhubUser)
} }
logger.debug('update user at humhub') logger.debug('update user at humhub')
const result = await syncUser(user, humhubUsers) const result = await syncUser(user, humhubUsers)

View File

@ -1,9 +1,22 @@
import { createUnionType } from 'type-graphql' import { createUnionType } from 'type-graphql'
import { ContributionLink } from '@model/ContributionLink' import { ContributionLink } from '@model/ContributionLink'
import { RedeemJwtLink } from '@model/RedeemJwtLink'
import { TransactionLink } from '@model/TransactionLink' import { TransactionLink } from '@model/TransactionLink'
export const QueryLinkResult = createUnionType({ export const QueryLinkResult = createUnionType({
name: 'QueryLinkResult', // the name of the GraphQL union name: 'QueryLinkResult', // the name of the GraphQL union
types: () => [TransactionLink, ContributionLink] as const, // function that returns tuple of object types classes types: () => [TransactionLink, RedeemJwtLink, ContributionLink] as const, // function that returns tuple of object types classes
resolveType: (value: TransactionLink | RedeemJwtLink | ContributionLink) => {
if (value instanceof TransactionLink) {
return TransactionLink
}
if (value instanceof RedeemJwtLink) {
return RedeemJwtLink
}
if (value instanceof ContributionLink) {
return ContributionLink
}
return null
},
}) })

View File

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

View File

@ -36,9 +36,11 @@ export const createServer = async (
// open mariadb connection, retry connecting with mariadb // open mariadb connection, retry connecting with mariadb
// check for correct database version // check for correct database version
// retry max 15 times, wait 500 ms between tries // retry max CONFIG.DB_CONNECT_RETRY_COUNT times, wait CONFIG.DB_CONNECT_RETRY_DELAY ms between tries
// TODO: move variables into config const con = await checkDBVersionUntil(
const con = await checkDBVersionUntil(15, 500) CONFIG.DB_CONNECT_RETRY_COUNT,
CONFIG.DB_CONNECT_RETRY_DELAY_MS,
)
// Express Server // Express Server
const app = express() const app = express()

View File

@ -6,7 +6,7 @@ import { CONFIG } from '@/config'
import { Connection } from '@/typeorm/connection' import { Connection } from '@/typeorm/connection'
import { Connection as DbConnection } from 'typeorm' import { Connection as DbConnection } from 'typeorm'
async function checkDBVersionUntil(maxRetries = 15, delayMs = 500): Promise<DbConnection> { async function checkDBVersionUntil(maxRetries: number, delayMs: number): Promise<DbConnection> {
for (let attempt = 1; attempt <= maxRetries; attempt++) { for (let attempt = 1; attempt <= maxRetries; attempt++) {
try { try {
const connection = await Connection.getInstance() const connection = await Connection.getInstance()

View File

@ -1,9 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-empty-interface */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { Decimal } from 'decimal.js-light' import { Decimal } from 'decimal.js-light'
expect.extend({ expect.extend({
@ -28,7 +22,6 @@ interface CustomMatchers<R = unknown> {
} }
declare global { declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest { namespace jest {
interface Expect extends CustomMatchers {} interface Expect extends CustomMatchers {}
interface Matchers<R> extends CustomMatchers<R> {} interface Matchers<R> extends CustomMatchers<R> {}

View File

@ -1,10 +1,3 @@
/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
import { createTestClient } from 'apollo-server-testing' import { createTestClient } from 'apollo-server-testing'
import { entities } from 'database' import { entities } from 'database'

View File

@ -1,4 +1,3 @@
// eslint-disable-next-line import/no-unassigned-import
import 'openai/shims/node' import 'openai/shims/node'
import { CONFIG } from '@/config' import { CONFIG } from '@/config'
import { i18n } from '@/server/localization' import { i18n } from '@/server/localization'

View File

@ -37,6 +37,20 @@ export const DB_VERSION = Joi.string()
) )
.required() .required()
export const DB_CONNECT_RETRY_COUNT = Joi.number()
.default(15)
.min(1)
.max(1000)
.description('Number of retries to connect to the database')
.optional()
export const DB_CONNECT_RETRY_DELAY_MS = Joi.number()
.default(500)
.min(100)
.max(10000)
.description('Delay in milliseconds between retries to connect to the database')
.optional()
export const COMMUNITY_URL = Joi.string() export const COMMUNITY_URL = Joi.string()
.uri({ scheme: ['http', 'https'] }) .uri({ scheme: ['http', 'https'] })
.custom((value: string, helpers: Joi.CustomHelpers<string>) => { .custom((value: string, helpers: Joi.CustomHelpers<string>) => {

View File

@ -17,12 +17,12 @@
"scripts": { "scripts": {
"build": "tsx ./esbuild.config.ts", "build": "tsx ./esbuild.config.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "biome check --error-on-warnings .",
"lint:fix": "biome check --error-on-warnings . --write",
"clear": "cross-env TZ=UTC tsx src/index.ts clear",
"up": "cross-env TZ=UTC tsx src/index.ts up", "up": "cross-env TZ=UTC tsx src/index.ts up",
"down": "cross-env TZ=UTC tsx src/index.ts down", "down": "cross-env TZ=UTC tsx src/index.ts down",
"reset": "cross-env TZ=UTC tsx src/index.ts reset", "reset": "cross-env TZ=UTC tsx src/index.ts reset",
"clear": "cross-env TZ=UTC tsx src/index.ts clear",
"lint": "biome check --error-on-warnings .",
"lint:fix": "biome check --error-on-warnings . --write",
"up:backend_test": "cross-env TZ=UTC DB_DATABASE=gradido_test_backend tsx src/index.ts up", "up:backend_test": "cross-env TZ=UTC DB_DATABASE=gradido_test_backend tsx src/index.ts up",
"up:federation_test": "cross-env TZ=UTC DB_DATABASE=gradido_test_federation tsx src/index.ts up", "up:federation_test": "cross-env TZ=UTC DB_DATABASE=gradido_test_federation tsx src/index.ts up",
"up:dht_test": "cross-env TZ=UTC DB_DATABASE=gradido_test_dht tsx src/index.ts up" "up:dht_test": "cross-env TZ=UTC DB_DATABASE=gradido_test_dht tsx src/index.ts up"

View File

@ -27,7 +27,10 @@ export async function truncateTables(connection: Connection) {
} }
export async function clearDatabase() { export async function clearDatabase() {
const connection = await connectToDatabaseServer() const connection = await connectToDatabaseServer(
CONFIG.DB_CONNECT_RETRY_COUNT,
CONFIG.DB_CONNECT_RETRY_DELAY_MS,
)
if (!connection) { if (!connection) {
throw new Error('Could not connect to database server') throw new Error('Could not connect to database server')
} }

View File

@ -11,11 +11,17 @@ const constants = {
} }
const database = { const database = {
DB_HOST: process.env.DB_HOST || 'localhost', DB_CONNECT_RETRY_COUNT: process.env.DB_CONNECT_RETRY_COUNT
? Number.parseInt(process.env.DB_CONNECT_RETRY_COUNT)
: 15,
DB_CONNECT_RETRY_DELAY_MS: process.env.DB_CONNECT_RETRY_DELAY_MS
? Number.parseInt(process.env.DB_CONNECT_RETRY_DELAY_MS)
: 500,
DB_HOST: process.env.DB_HOST ?? 'localhost',
DB_PORT: process.env.DB_PORT ? Number.parseInt(process.env.DB_PORT) : 3306, DB_PORT: process.env.DB_PORT ? Number.parseInt(process.env.DB_PORT) : 3306,
DB_USER: process.env.DB_USER || 'root', DB_USER: process.env.DB_USER ?? 'root',
DB_PASSWORD: process.env.DB_PASSWORD || '', DB_PASSWORD: process.env.DB_PASSWORD ?? '',
DB_DATABASE: process.env.DB_DATABASE || 'gradido_community', DB_DATABASE: process.env.DB_DATABASE ?? 'gradido_community',
} }
const migrations = { const migrations = {

View File

@ -11,8 +11,8 @@ export enum DatabaseState {
} }
export async function connectToDatabaseServer( export async function connectToDatabaseServer(
maxRetries = 15, maxRetries: number,
delayMs = 500, delayMs: number,
): Promise<Connection | null> { ): Promise<Connection | null> {
for (let attempt = 1; attempt <= maxRetries; attempt++) { for (let attempt = 1; attempt <= maxRetries; attempt++) {
try { try {
@ -42,7 +42,10 @@ async function convertJsToTsInMigrations(connection: Connection): Promise<number
} }
export const getDatabaseState = async (): Promise<DatabaseState> => { export const getDatabaseState = async (): Promise<DatabaseState> => {
const connection = await connectToDatabaseServer() const connection = await connectToDatabaseServer(
CONFIG.DB_CONNECT_RETRY_COUNT,
CONFIG.DB_CONNECT_RETRY_DELAY_MS,
)
if (!connection) { if (!connection) {
return DatabaseState.NOT_CONNECTED return DatabaseState.NOT_CONNECTED
} }

View File

@ -14,13 +14,16 @@
"cache": false "cache": false
}, },
"up": { "up": {
"cache": false "cache": false,
"dependsOn": ["build"]
}, },
"down": { "down": {
"cache": false "cache": false,
"dependsOn": ["build"]
}, },
"reset": { "reset": {
"cache": false "cache": false,
"dependsOn": ["build"]
} }
} }
} }

View File

@ -22,18 +22,11 @@ done
# set $1, $2, ... only with position arguments # set $1, $2, ... only with position arguments
set -- "${POSITIONAL_ARGS[@]}" set -- "${POSITIONAL_ARGS[@]}"
# check for missing branch name
if [ -z "$1" ]; then
echo "Usage: $0 [--fast] <branchName>"
exit 1
fi
BRANCH_NAME="$1" BRANCH_NAME="$1"
# Debug-Ausgabe # check for parameter
if [ -z "$1" ]; then if [ -z "$BRANCH_NAME" ]; then
echo "Usage: Please provide a branch name as the first argument." echo "Usage: $0 [--fast] <branchName> [--fast]"
exit 1 exit 1
fi fi
echo "Use branch: $BRANCH_NAME" echo "Use branch: $BRANCH_NAME"
@ -51,9 +44,17 @@ PROJECT_ROOT=$SCRIPT_DIR/../..
NGINX_CONFIG_DIR=$SCRIPT_DIR/nginx/sites-available NGINX_CONFIG_DIR=$SCRIPT_DIR/nginx/sites-available
set +o allexport set +o allexport
# enable nvm # enable nvm
export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
nvm use install_nvm() {
nvm install
nvm use
nvm alias default
npm i -g yarn pm2
pm2 startup
}
# make sure correct node version is installed
nvm use || install_nvm
# NOTE: all config values will be in process.env when starting # NOTE: all config values will be in process.env when starting
# the services and will therefore take precedence over the .env # the services and will therefore take precedence over the .env
@ -170,14 +171,12 @@ else
log_warn "PM2 is already empty" log_warn "PM2 is already empty"
fi fi
# git # git
BRANCH=$1 log_step "Starting with git pull - branch:$BRANCH_NAME"
log_step "Starting with git pull - branch:$BRANCH"
cd $PROJECT_ROOT cd $PROJECT_ROOT
# TODO: this overfetches alot, but ensures we can use start.sh with tags # TODO: this overfetches alot, but ensures we can use start.sh with tags
git fetch --all git fetch --all
git checkout $BRANCH git checkout $BRANCH_NAME
git pull git pull
export BUILD_COMMIT="$(git rev-parse HEAD)" export BUILD_COMMIT="$(git rev-parse HEAD)"
@ -289,7 +288,6 @@ else
turbo up --env-mode=loose turbo up --env-mode=loose
fi fi
nvm use default
# start after building all to use up less ressources # start after building all to use up less ressources
pm2 start --name gradido-backend "turbo backend#start --env-mode=loose" -l $GRADIDO_LOG_PATH/pm2.backend.$TODAY.log --log-date-format 'YYYY-MM-DD HH:mm:ss.SSS' pm2 start --name gradido-backend "turbo backend#start --env-mode=loose" -l $GRADIDO_LOG_PATH/pm2.backend.$TODAY.log --log-date-format 'YYYY-MM-DD HH:mm:ss.SSS'
#pm2 start --name gradido-frontend "yarn --cwd $PROJECT_ROOT/frontend start" -l $GRADIDO_LOG_PATH/pm2.frontend.$TODAY.log --log-date-format 'YYYY-MM-DD HH:mm:ss.SSS' #pm2 start --name gradido-frontend "yarn --cwd $PROJECT_ROOT/frontend start" -l $GRADIDO_LOG_PATH/pm2.frontend.$TODAY.log --log-date-format 'YYYY-MM-DD HH:mm:ss.SSS'
@ -335,4 +333,4 @@ cat $UPDATE_HTML >> $GRADIDO_LOG_PATH/update.$TODAY.log
log_success " /\\_/\\ " log_success " /\\_/\\ "
log_success "( ^.^ ) Update finished successfully!" log_success "( ^.^ ) Update finished successfully!"
log_success " > <" log_success " > <"

View File

@ -1,5 +1,4 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
// eslint-disable-next-line import/no-commonjs, import/unambiguous
module.exports = { module.exports = {
verbose: true, verbose: true,
preset: 'ts-jest', preset: 'ts-jest',

View File

@ -1,4 +1,3 @@
/* eslint-disable n/no-process-env */
import { validate } from 'config-schema' import { validate } from 'config-schema'
import { latestDbVersion } from 'database' import { latestDbVersion } from 'database'
import dotenv from 'dotenv' import dotenv from 'dotenv'
@ -19,6 +18,12 @@ const server = {
} }
const database = { const database = {
DB_CONNECT_RETRY_COUNT: process.env.DB_CONNECT_RETRY_COUNT
? Number.parseInt(process.env.DB_CONNECT_RETRY_COUNT)
: 15,
DB_CONNECT_RETRY_DELAY_MS: process.env.DB_CONNECT_RETRY_DELAY_MS
? Number.parseInt(process.env.DB_CONNECT_RETRY_DELAY_MS)
: 500,
DB_HOST: process.env.DB_HOST ?? 'localhost', DB_HOST: process.env.DB_HOST ?? 'localhost',
DB_PORT: process.env.DB_PORT ? Number.parseInt(process.env.DB_PORT) : 3306, DB_PORT: process.env.DB_PORT ? Number.parseInt(process.env.DB_PORT) : 3306,
DB_USER: process.env.DB_USER ?? 'root', DB_USER: process.env.DB_USER ?? 'root',

View File

@ -1,6 +1,8 @@
import { import {
COMMUNITY_DESCRIPTION, COMMUNITY_DESCRIPTION,
COMMUNITY_NAME, COMMUNITY_NAME,
DB_CONNECT_RETRY_COUNT,
DB_CONNECT_RETRY_DELAY_MS,
DB_DATABASE, DB_DATABASE,
DB_HOST, DB_HOST,
DB_PASSWORD, DB_PASSWORD,
@ -19,6 +21,8 @@ export const schema = Joi.object({
COMMUNITY_NAME, COMMUNITY_NAME,
COMMUNITY_DESCRIPTION, COMMUNITY_DESCRIPTION,
DB_DATABASE, DB_DATABASE,
DB_CONNECT_RETRY_COUNT,
DB_CONNECT_RETRY_DELAY_MS,
DB_HOST, DB_HOST,
DB_PASSWORD, DB_PASSWORD,
DB_PORT, DB_PORT,

View File

@ -1,6 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import DHT from '@hyperswarm/dht' import DHT from '@hyperswarm/dht'
import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity } from 'database' import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity } from 'database'
import { validate as validateUUID, version as versionUUID } from 'uuid' import { validate as validateUUID, version as versionUUID } from 'uuid'

View File

@ -1,5 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import DHT from '@hyperswarm/dht' import DHT from '@hyperswarm/dht'
import { import {
CommunityLoggingView, CommunityLoggingView,

View File

@ -6,7 +6,7 @@ import { checkDBVersionUntil } from './typeorm/DBVersion'
async function main() { async function main() {
// open mysql connection // open mysql connection
await checkDBVersionUntil() await checkDBVersionUntil(CONFIG.DB_CONNECT_RETRY_COUNT, CONFIG.DB_CONNECT_RETRY_DELAY_MS)
logger.debug(`dhtseed set by CONFIG.FEDERATION_DHT_SEED=${CONFIG.FEDERATION_DHT_SEED}`) logger.debug(`dhtseed set by CONFIG.FEDERATION_DHT_SEED=${CONFIG.FEDERATION_DHT_SEED}`)
logger.info( logger.info(
`starting Federation on ${CONFIG.FEDERATION_DHT_TOPIC} ${ `starting Federation on ${CONFIG.FEDERATION_DHT_TOPIC} ${

View File

@ -6,7 +6,7 @@ import { CONFIG } from '@/config'
import { Connection as DbConnection } from 'typeorm' import { Connection as DbConnection } from 'typeorm'
import { connection as connectionFunc } from './connection' import { connection as connectionFunc } from './connection'
async function checkDBVersionUntil(maxRetries = 15, delayMs = 500): Promise<DbConnection> { async function checkDBVersionUntil(maxRetries: number, delayMs: number): Promise<DbConnection> {
for (let attempt = 1; attempt <= maxRetries; attempt++) { for (let attempt = 1; attempt <= maxRetries; attempt++) {
try { try {
const connection = await connectionFunc() const connection = await connectionFunc()

View File

@ -1,6 +1,7 @@
import { entities } from 'database' import { entities } from 'database'
import { checkDBVersionUntil } from '@/typeorm/DBVersion' import { checkDBVersionUntil } from '@/typeorm/DBVersion'
import { CONFIG } from '@/config'
export const headerPushMock = jest.fn((t) => { export const headerPushMock = jest.fn((t) => {
context.token = t.value context.token = t.value
@ -23,7 +24,7 @@ export const cleanDB = async () => {
} }
export const testEnvironment = async () => { export const testEnvironment = async () => {
return { con: await checkDBVersionUntil() } return { con: await checkDBVersionUntil(CONFIG.DB_CONNECT_RETRY_COUNT, CONFIG.DB_CONNECT_RETRY_DELAY_MS) }
} }
export const resetEntity = async (entity: any) => { export const resetEntity = async (entity: any) => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -25,7 +25,6 @@ module.exports = {
? '<rootDir>/../database/entity/$1' ? '<rootDir>/../database/entity/$1'
: '<rootDir>/../database/build/entity/$1', : '<rootDir>/../database/build/entity/$1',
'@logging/(.*)': '@logging/(.*)':
// eslint-disable-next-line n/no-process-env
process.env.NODE_ENV === 'development' process.env.NODE_ENV === 'development'
? '<rootDir>/../database/logging/$1' ? '<rootDir>/../database/logging/$1'
: '<rootDir>/../database/build/logging/$1', : '<rootDir>/../database/build/logging/$1',
@ -34,7 +33,6 @@ module.exports = {
? '<rootDir>/../database/src/$1' ? '<rootDir>/../database/src/$1'
: '<rootDir>/../database/build/src/$1', : '<rootDir>/../database/build/src/$1',
'@config/(.*)': '@config/(.*)':
// eslint-disable-next-line n/no-process-env
process.env.NODE_ENV === 'development' process.env.NODE_ENV === 'development'
? '<rootDir>/../config/src/$1' ? '<rootDir>/../config/src/$1'
: '<rootDir>/../config/build/$1', : '<rootDir>/../config/build/$1',

View File

@ -30,6 +30,12 @@ const server = {
PRODUCTION: process.env.NODE_ENV === 'production', PRODUCTION: process.env.NODE_ENV === 'production',
} }
const database = { const database = {
DB_CONNECT_RETRY_COUNT: process.env.DB_CONNECT_RETRY_COUNT
? Number.parseInt(process.env.DB_CONNECT_RETRY_COUNT)
: 15,
DB_CONNECT_RETRY_DELAY_MS: process.env.DB_CONNECT_RETRY_DELAY_MS
? Number.parseInt(process.env.DB_CONNECT_RETRY_DELAY_MS)
: 500,
DB_HOST: process.env.DB_HOST ?? 'localhost', DB_HOST: process.env.DB_HOST ?? 'localhost',
DB_PORT: process.env.DB_PORT ? Number.parseInt(process.env.DB_PORT) : 3306, DB_PORT: process.env.DB_PORT ? Number.parseInt(process.env.DB_PORT) : 3306,
DB_USER: process.env.DB_USER ?? 'root', DB_USER: process.env.DB_USER ?? 'root',

View File

@ -1,4 +1,6 @@
import { import {
DB_CONNECT_RETRY_COUNT,
DB_CONNECT_RETRY_DELAY_MS,
DB_DATABASE, DB_DATABASE,
DB_HOST, DB_HOST,
DB_PASSWORD, DB_PASSWORD,
@ -17,6 +19,8 @@ import Joi from 'joi'
export const schema = Joi.object({ export const schema = Joi.object({
DB_DATABASE, DB_DATABASE,
DB_CONNECT_RETRY_COUNT,
DB_CONNECT_RETRY_DELAY_MS,
DB_HOST, DB_HOST,
DB_PASSWORD, DB_PASSWORD,
DB_PORT, DB_PORT,

View File

@ -17,6 +17,7 @@ import { schema } from '@/graphql/schema'
// import { elopageWebhook } from '@/webhook/elopage' // import { elopageWebhook } from '@/webhook/elopage'
import { Connection } from 'typeorm' import { Connection } from 'typeorm'
import { CONFIG } from '@/config'
import { slowDown } from 'express-slow-down' import { slowDown } from 'express-slow-down'
import helmet from 'helmet' import helmet from 'helmet'
import { Logger } from 'log4js' import { Logger } from 'log4js'
@ -39,7 +40,10 @@ export const createServer = async (
logger.debug('createServer...') logger.debug('createServer...')
// open mysql connection // open mysql connection
const con = await checkDBVersionUntil() const con = await checkDBVersionUntil(
CONFIG.DB_CONNECT_RETRY_COUNT,
CONFIG.DB_CONNECT_RETRY_DELAY_MS,
)
// Express Server // Express Server
const app = express() const app = express()

View File

@ -4,7 +4,7 @@ import { Migration } from 'database'
import { Connection as DbConnection } from 'typeorm' import { Connection as DbConnection } from 'typeorm'
import { connection as connectionFunc } from './connection' import { connection as connectionFunc } from './connection'
async function checkDBVersionUntil(maxRetries = 15, delayMs = 500): Promise<DbConnection> { async function checkDBVersionUntil(maxRetries: number, delayMs: number): Promise<DbConnection> {
for (let attempt = 1; attempt <= maxRetries; attempt++) { for (let attempt = 1; attempt <= maxRetries; attempt++) {
try { try {
const connection = await connectionFunc() const connection = await connectionFunc()

View File

@ -46,6 +46,7 @@ const validCommunityIdentifier = ref(false)
const { onResult } = useQuery(selectCommunities) const { onResult } = useQuery(selectCommunities)
onResult(({ data }) => { onResult(({ data }) => {
// console.log('CommunitySwitch.onResult...data=', data)
if (data) { if (data) {
communities.value = data.communities communities.value = data.communities
setDefaultCommunity() setDefaultCommunity()
@ -55,22 +56,42 @@ onResult(({ data }) => {
const communityIdentifier = computed(() => route.params.communityIdentifier) const communityIdentifier = computed(() => route.params.communityIdentifier)
function updateCommunity(community) { function updateCommunity(community) {
// console.log('CommunitySwitch.updateCommunity...community=', community)
emit('update:model-value', community) emit('update:model-value', community)
} }
function setDefaultCommunity() { function setDefaultCommunity() {
// console.log(
// 'CommunitySwitch.setDefaultCommunity... communityIdentifier= communities=',
// communityIdentifier,
// communities,
// )
if (communityIdentifier.value && communities.value.length >= 1) { if (communityIdentifier.value && communities.value.length >= 1) {
// console.log(
// 'CommunitySwitch.setDefaultCommunity... communities.value.length=',
// communities.value.length,
// )
const foundCommunity = communities.value.find((community) => { const foundCommunity = communities.value.find((community) => {
// console.log('CommunitySwitch.setDefaultCommunity... community=', community)
if ( if (
community.uuid === communityIdentifier.value || community.uuid === communityIdentifier.value ||
community.name === communityIdentifier.value community.name === communityIdentifier.value
) { ) {
validCommunityIdentifier.value = true validCommunityIdentifier.value = true
// console.log(
// 'CommunitySwitch.setDefaultCommunity...true validCommunityIdentifier=',
// validCommunityIdentifier,
// )
return true return true
} }
// console.log(
// 'CommunitySwitch.setDefaultCommunity...false validCommunityIdentifier=',
// validCommunityIdentifier,
// )
return false return false
}) })
if (foundCommunity) { if (foundCommunity) {
// console.log('CommunitySwitch.setDefaultCommunity...foundCommunity=', foundCommunity)
updateCommunity(foundCommunity) updateCommunity(foundCommunity)
return return
} }
@ -79,10 +100,20 @@ function setDefaultCommunity() {
if (validCommunityIdentifier.value && !communityIdentifier.value) { if (validCommunityIdentifier.value && !communityIdentifier.value) {
validCommunityIdentifier.value = false validCommunityIdentifier.value = false
// console.log(
// 'CommunitySwitch.setDefaultCommunity...validCommunityIdentifier=',
// validCommunityIdentifier,
// )
} }
if (props.modelValue?.uuid === '' && communities.value.length) { if (props.modelValue?.uuid === '' && communities.value.length) {
// console.log(
// 'CommunitySwitch.setDefaultCommunity...props.modelValue= communities=',
// props.modelValue,
// communities.value.length,
// )
const foundCommunity = communities.value.find((community) => !community.foreign) const foundCommunity = communities.value.find((community) => !community.foreign)
// console.log('CommunitySwitch.setDefaultCommunity...foundCommunity=', foundCommunity)
if (foundCommunity) { if (foundCommunity) {
updateCommunity(foundCommunity) updateCommunity(foundCommunity)
} }

View File

@ -0,0 +1,182 @@
<template>
<div
:link-data="linkData"
:redeem-code="redeemCode"
:is-contribution-link="isContributionLink"
:is-redeem-jwt-link="isRedeemJwtLink"
class="redeem-community-selection"
>
<BCard bg-variant="muted" text-variant="dark" border-variant="info">
<h1 v-if="linkData.amount === ''">{{ $t('gdd_per_link.redeemlink-error') }}</h1>
<h1 v-if="!isContributionLink && linkData.amount !== ''">
<BCol class="mb-4" cols="12">
<BRow>
<BCol v-if="!isRedeemJwtLink">
{{ $t('gdd_per_link.recipientCommunitySelection') }}
</BCol>
<BCol v-else>{{ $t('gdd_per_link.recipientCommunityFix') }}</BCol>
</BRow>
<h3>
<BRow>
<BCol v-if="!isRedeemJwtLink" class="fw-bold">
<community-switch
:disabled="isRedeemJwtLink"
:model-value="currentRecipientCommunity"
@update:model-value="setRecipientCommunity"
/>
</BCol>
<BCol v-else>
{{ currentRecipientCommunity.name }}
</BCol>
<BCol v-if="isForeignCommunitySelected" sm="12" md="6" class="mt-4 mt-lg-0">
<p>{{ $t('gdd_per_link.switchCommunity') }}</p>
<BButton variant="gradido" @click="onSwitch">
{{ $t('gdd_per_link.to-switch') }}
</BButton>
</BCol>
</BRow>
</h3>
</BCol>
<template v-if="linkData.senderUser">
{{ linkData.senderUser.firstName }}
{{ $t('transaction-link.send_you') }} {{ $filters.GDD(linkData.amount) }}
</template>
</h1>
<b>{{ linkData.memo }}</b>
</BCard>
</div>
</template>
<script setup>
import CONFIG from '@/config'
import { computed } from 'vue'
import { createRedeemJwtMutation } from '@/graphql/mutations'
import { useMutation } from '@vue/apollo-composable'
const props = defineProps({
linkData: { type: Object, required: true },
redeemCode: { type: String, required: true },
isContributionLink: { type: Boolean, default: false },
isRedeemJwtLink: { type: Boolean, default: false },
recipientCommunity: {
type: Object,
required: false,
},
})
const senderCommunity = computed(() => extractHomeCommunityFromLinkData(props.linkData))
const currentRecipientCommunity = computed(
() =>
props.recipientCommunity || {
uuid: senderCommunity.value.uuid,
name: senderCommunity.value.name,
url: senderCommunity.value.url,
foreign: senderCommunity.value.foreign,
},
)
const emit = defineEmits(['update:recipientCommunity'])
const isForeignCommunitySelected = computed(() => {
// console.log(
// 'RedeemCommunitySelection.isForeignCommunitySelected...recipientCommunity=',
// currentRecipientCommunity.value,
// )
return currentRecipientCommunity.value.foreign
})
function setRecipientCommunity(community) {
// console.log('RedeemCommunitySelection.setRecipientCommunity...community=', community)
emit('update:recipientCommunity', {
uuid: community.uuid,
name: community.name,
url: community.url,
foreign: community.foreign,
})
}
function extractHomeCommunityFromLinkData(linkData) {
// console.log(
// 'RedeemCommunitySelection.extractHomeCommunityFromLinkData... props.linkData=',
// props.linkData,
// )
// console.log('RedeemCommunitySelection.extractHomeCommunityFromLinkData...linkData=', linkData)
// console.log(
// 'RedeemCommunitySelection.extractHomeCommunityFromLinkData...communities=',
// linkData.communities,
// )
// console.log(
// 'RedeemCommunitySelection.extractHomeCommunityFromLinkData...linkData.value=',
// linkData.value,
// )
if (linkData.communities?.length === 0) {
return {
uuid: '',
name: CONFIG.COMMUNITY_NAME,
url: CONFIG.COMMUNITY_URL,
foreign: false,
}
}
const communities = linkData.communities
// console.log(
// 'RedeemCommunitySelection.extractHomeCommunityFromLinkData...communities=',
// communities,
// )
const homeCommunity = communities?.find((c) => c.foreign === false)
// console.log(
// 'RedeemCommunitySelection.extractHomeCommunityFromLinkData...homeCommunity=',
// homeCommunity,
// )
return {
uuid: homeCommunity.uuid,
name: homeCommunity.name,
url: homeCommunity.url,
foreign: homeCommunity.foreign,
}
}
const { mutate: createRedeemJwt } = useMutation(createRedeemJwtMutation)
async function onSwitch(event) {
event.preventDefault() // Prevent the default navigation
// console.log('RedeemCommunitySelection.onSwitch... props=', props)
if (isForeignCommunitySelected.value) {
// console.log('RedeemCommunitySelection.onSwitch vor createRedeemJwt params:', {
// gradidoId: props.linkData.senderUser?.gradidoID,
// senderCommunityUuid: senderCommunity.value.uuid,
// senderCommunityName: senderCommunity.value.name,
// recipientCommunityUuid: currentRecipientCommunity.value.uuid,
// code: props.redeemCode,
// amount: props.linkData.amount,
// memo: props.linkData.memo,
// firstName: props.linkData.senderUser?.firstName,
// alias: props.linkData.senderUser?.alias,
// validUntil: props.linkData.validUntil,
// })
// eslint-disable-next-line no-useless-catch
try {
const { data } = await createRedeemJwt({
gradidoId: props.linkData.senderUser?.gradidoID,
senderCommunityUuid: senderCommunity.value.uuid,
senderCommunityName: senderCommunity.value.name,
recipientCommunityUuid: currentRecipientCommunity.value.uuid,
code: props.redeemCode,
amount: props.linkData.amount,
memo: props.linkData.memo,
firstName: props.linkData.senderUser?.firstName,
alias: props.linkData.senderUser?.alias,
validUntil: props.linkData.validUntil,
})
// console.log('RedeemCommunitySelection.onSwitch... response=', data)
if (!data?.createRedeemJwt) {
throw new Error('Failed to get redeem token')
}
const targetUrl = currentRecipientCommunity.value.url.replace(/\/api\/?$/, '')
window.location.href = targetUrl + '/redeem/' + data.createRedeemJwt
} catch (error) {
// console.error('RedeemCommunitySelection.onSwitch error:', error)
throw error
}
}
}
</script>

View File

@ -1,16 +1,20 @@
<template> <template>
<div class="redeem-information"> <div class="redeem-information">
<BCard bg-variant="muted" text-variant="dark" border-variant="info"> <BCard bg-variant="muted" text-variant="dark" border-variant="info">
<h1 v-if="amount === ''">{{ $t('gdd_per_link.redeemlink-error') }}</h1> <h1 v-if="linkData.amount === ''">{{ $t('gdd_per_link.redeemlink-error') }}</h1>
<h1 v-if="isContributionLink && amount !== ''"> <h1 v-if="isContributionLink && linkData.amount !== ''">
{{ CONFIG.COMMUNITY_NAME }} {{ CONFIG.COMMUNITY_NAME }}
{{ $t('contribution-link.thanksYouWith') }} {{ $filters.GDD(amount) }} {{ $t('contribution-link.thanksYouWith') }} {{ $filters.GDD(linkData.amount) }}
</h1> </h1>
<h1 v-if="!isContributionLink && amount !== ''"> <h3 v-if="isRedeemJwtLink && linkData.amount !== ''">
{{ user.firstName }} {{ '"' + linkData.senderCommunity.name + '.' + linkData.senderUser.firstName + '"' }}
{{ $t('transaction-link.send_you') }} {{ $filters.GDD(amount) }} {{ $t('transaction-link.send_you') }} {{ $filters.GDD(linkData.amount) }}
</h1> </h3>
<b>{{ memo }}</b> <h3 v-if="!isRedeemJwtLink && linkData.amount !== ''">
{{ '"' + linkData.senderUser.firstName + '"' }}
{{ $t('transaction-link.send_you') }} {{ $filters.GDD(linkData.amount) }}
</h3>
<b>{{ linkData.memo }}</b>
</BCard> </BCard>
</div> </div>
</template> </template>
@ -20,10 +24,9 @@ import CONFIG from '@/config'
export default { export default {
name: 'RedeemInformation', name: 'RedeemInformation',
props: { props: {
user: { type: Object, required: false }, linkData: { type: Object, required: true },
amount: { type: String, required: true },
memo: { type: String, required: true, default: '' },
isContributionLink: { type: Boolean, default: false }, isContributionLink: { type: Boolean, default: false },
isRedeemJwtLink: { type: Boolean, default: false },
}, },
data() { data() {
return { return {

View File

@ -0,0 +1,59 @@
<template>
<div class="redeem-select-community">
<redeem-community-selection
v-model:recipient-community="recipientCommunity"
:link-data="props.linkData"
:redeem-code="props.redeemCode"
:is-transaction-link-loaded="props.isTransactionLinkLoaded"
:is-contribution-link="props.isContributionLink"
:is-redeem-jwt-link="props.isRedeemJwtLink"
/>
<BCard v-if="props.isTransactionLinkLoaded">
<div class="mb-2">
<h2>{{ $t('gdd_per_link.redeem') }}</h2>
</div>
<BRow>
<BCol sm="12" md="6">
<p>{{ $t('gdd_per_link.no-account') }}</p>
<BButton variant="primary" :disabled="isForeignCommunitySelected" :to="register()">
{{ $t('gdd_per_link.to-register') }}
</BButton>
</BCol>
<BCol sm="12" md="6" class="mt-4 mt-lg-0">
<p>{{ $t('gdd_per_link.has-account') }}</p>
<BButton variant="gradido" :disabled="isForeignCommunitySelected" :to="login()">
{{ $t('gdd_per_link.to-login') }}
</BButton>
</BCol>
</BRow>
</BCard>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import CONFIG from '@/config'
import { useAuthLinks } from '@/composables/useAuthLinks'
const { login, register } = useAuthLinks()
const props = defineProps({
linkData: { type: Object, required: true },
redeemCode: { type: String, required: true },
isContributionLink: { type: Boolean, default: false },
isRedeemJwtLink: { type: Boolean, default: false },
isTransactionLinkLoaded: { type: Boolean, default: false },
})
const recipientCommunity = ref({
uuid: '',
name: CONFIG.COMMUNITY_NAME,
url: CONFIG.COMMUNITY_URL,
foreign: false,
})
const isForeignCommunitySelected = computed(() => {
return recipientCommunity.value.foreign === true
})
</script>

View File

@ -1,6 +1,11 @@
<template> <template>
<div class="redeem-valid"> <div class="redeem-valid">
<redeem-information v-bind="linkData" :is-contribution-link="isContributionLink" /> <redeem-information
:link-data="linkData"
:is-contribution-link="isContributionLink"
:is-redeem-jwt-link="isRedeemJwtLink"
:valid-link="validLink"
/>
<BCard> <BCard>
<div class="mb-3 text-center"> <div class="mb-3 text-center">
<BButton <BButton
@ -26,6 +31,7 @@ export default {
props: { props: {
linkData: { type: Object, required: true }, linkData: { type: Object, required: true },
isContributionLink: { type: Boolean, default: false }, isContributionLink: { type: Boolean, default: false },
isRedeemJwtLink: { type: Boolean, default: false },
validLink: { type: Boolean, default: false }, validLink: { type: Boolean, default: false },
}, },
} }

View File

@ -209,3 +209,61 @@ export const logout = gql`
logout logout
} }
` `
export const createRedeemJwtMutation = gql`
mutation (
$gradidoId: String!
$senderCommunityUuid: String!
$senderCommunityName: String!
$recipientCommunityUuid: String!
$code: String!
$amount: String!
$memo: String!
$firstName: String
$alias: String
$validUntil: String
) {
createRedeemJwt(
gradidoId: $gradidoId
senderCommunityUuid: $senderCommunityUuid
senderCommunityName: $senderCommunityName
recipientCommunityUuid: $recipientCommunityUuid
code: $code
amount: $amount
memo: $memo
firstName: $firstName
alias: $alias
validUntil: $validUntil
)
}
`
export const disburseTransactionLink = gql`
mutation (
$senderCommunityUuid: String!
$senderGradidoId: String!
$recipientCommunityUuid: String!
$recipientCommunityName: String!
$recipientGradidoId: String!
$recipientFirstName: String!
$code: String!
$amount: String!
$memo: String!
$validUntil: String
$recipientAlias: String
) {
disburseTransactionLink(
senderCommunityUuid: $senderCommunityUuid
senderGradidoId: $senderGradidoId
recipientCommunityUuid: $recipientCommunityUuid
recipientCommunityName: $recipientCommunityName
recipientGradidoId: $recipientGradidoId
recipientFirstName: $recipientFirstName
code: $code
amount: $amount
memo: $memo
validUntil: $validUntil
recipientAlias: $recipientAlias
)
}
`

View File

@ -99,6 +99,7 @@ export const selectCommunities = gql`
name name
description description
foreign foreign
url
} }
} }
` `
@ -126,7 +127,43 @@ export const queryTransactionLink = gql`
validUntil validUntil
redeemedAt redeemedAt
deletedAt deletedAt
user { senderUser {
gradidoID
firstName
publisherId
}
communities {
foreign
name
description
url
uuid
}
}
... on RedeemJwtLink {
amount
memo
code
validUntil
senderCommunity {
foreign
name
description
url
uuid
}
senderUser {
gradidoID
firstName
}
recipientCommunity {
foreign
name
description
url
uuid
}
recipientUser {
gradidoID gradidoID
firstName firstName
publisherId publisherId

View File

@ -230,6 +230,7 @@
"credit-your-gradido": "Damit die Gradido gutgeschrieben werden können, klicke auf den Link!", "credit-your-gradido": "Damit die Gradido gutgeschrieben werden können, klicke auf den Link!",
"delete-the-link": "Den Link löschen?", "delete-the-link": "Den Link löschen?",
"deleted": "Der Link wurde gelöscht!", "deleted": "Der Link wurde gelöscht!",
"disbured": "Auszahlung des Link-Guthabens erfolgreich initiiert! Die Gutschrift von {n} GDD wird zeitnah auf dein Konto gebucht",
"expiredOn": "Abgelaufen am", "expiredOn": "Abgelaufen am",
"has-account": "Du besitzt bereits ein Gradido Konto?", "has-account": "Du besitzt bereits ein Gradido Konto?",
"header": "Gradidos versenden per Link", "header": "Gradidos versenden per Link",
@ -245,12 +246,16 @@
"no-account": "Du besitzt noch kein Gradido Konto?", "no-account": "Du besitzt noch kein Gradido Konto?",
"no-redeem": "Du darfst deinen eigenen Link nicht einlösen!", "no-redeem": "Du darfst deinen eigenen Link nicht einlösen!",
"not-copied": "Dein Gerät lässt das Kopieren leider nicht zu! Bitte kopiere den Link von Hand!", "not-copied": "Dein Gerät lässt das Kopieren leider nicht zu! Bitte kopiere den Link von Hand!",
"recipientCommunityFix": "Empfänger-Gemeinschaft des Link-Guthabens...",
"recipientCommunitySelection": "Wähle deine Gemeinschaft zum Einlösen des Link-Guthabens...",
"redeem": "Einlösen", "redeem": "Einlösen",
"redeemed": "Erfolgreich eingelöst! Deinem Konto wurden {n} GDD gutgeschrieben.", "redeemed": "Erfolgreich eingelöst! Deinem Konto wurden {n} GDD gutgeschrieben.",
"redeemed-at": "Der Link wurde bereits am {date} eingelöst.", "redeemed-at": "Der Link wurde bereits am {date} eingelöst.",
"redeemlink-error": "Dieser Einlöse-Link ist nicht vollständig.", "redeemlink-error": "Dieser Einlöse-Link ist nicht vollständig.",
"switchCommunity": "Du hast eine andere Gemeinschaft ausgewählt...",
"to-login": "Log dich ein", "to-login": "Log dich ein",
"to-register": "Registriere ein neues Konto.", "to-register": "Registriere ein neues Konto.",
"to-switch": "Wechsle zur Gemeinschaft",
"validUntil": "Gültig bis", "validUntil": "Gültig bis",
"validUntilDate": "Der Link ist bis zum {date} gültig." "validUntilDate": "Der Link ist bis zum {date} gültig."
}, },
@ -413,7 +418,7 @@
"first": "Vorname", "first": "Vorname",
"first-tooltip": "Nur der Vornamen", "first-tooltip": "Nur der Vornamen",
"first-initial": "Vorname und Initial", "first-initial": "Vorname und Initial",
"first-initial-tooltip": "Vornamen plus die ersten beiden Anfangsbuchstabe des Nachnamens", "first-initial-tooltip": "Vornamen plus den ersten Anfangsbuchstaben des Nachnamens",
"initials": "Initialen", "initials": "Initialen",
"initials-tooltip": "Initialen von Vor- und Nachname also jeweils die ersten zwei Buchstaben unabhängig von der Existenz des Benutzernamens", "initials-tooltip": "Initialen von Vor- und Nachname also jeweils die ersten zwei Buchstaben unabhängig von der Existenz des Benutzernamens",
"name-full": "Vorname und Nachname", "name-full": "Vorname und Nachname",

View File

@ -230,6 +230,7 @@
"credit-your-gradido": "For the Gradido to be credited, click on the link!", "credit-your-gradido": "For the Gradido to be credited, click on the link!",
"delete-the-link": "Delete the link?", "delete-the-link": "Delete the link?",
"deleted": "The link was deleted!", "deleted": "The link was deleted!",
"disbured": "Disbursement of the Link-Ammount initiated! The transfer of {n} GDD into your account will be completed shortly.",
"expiredOn": "Expired on", "expiredOn": "Expired on",
"has-account": "You already have a Gradido account?", "has-account": "You already have a Gradido account?",
"header": "Send Gradidos via link", "header": "Send Gradidos via link",
@ -245,12 +246,16 @@
"no-account": "You don't have a Gradido account yet?", "no-account": "You don't have a Gradido account yet?",
"no-redeem": "You not allowed to redeem your own link!", "no-redeem": "You not allowed to redeem your own link!",
"not-copied": "Unfortunately, your device does not allow copying! Please copy the link by hand!", "not-copied": "Unfortunately, your device does not allow copying! Please copy the link by hand!",
"recipientCommunityFix": "Recipient Community of the Link-Balance...",
"recipientCommunitySelection": "Select your Community to redeem the link-deposit...",
"redeem": "Redeem", "redeem": "Redeem",
"redeemed": "Successfully redeemed! Your account has been credited with {n} GDD.", "redeemed": "Successfully redeemed! Your account has been credited with {n} GDD.",
"redeemed-at": "The link was already redeemed on {date}.", "redeemed-at": "The link was already redeemed on {date}.",
"redeemlink-error": "This redemption link is not complete.", "redeemlink-error": "This redemption link is not complete.",
"switchCommunity": "You have selected a foreign Community...",
"to-login": "Log in", "to-login": "Log in",
"to-register": "Register a new account.", "to-register": "Register a new account.",
"to-switch": "Switch to Community",
"validUntil": "Valid until", "validUntil": "Valid until",
"validUntilDate": "The link is valid until {date}." "validUntilDate": "The link is valid until {date}."
}, },
@ -413,7 +418,7 @@
"first": "Firstname", "first": "Firstname",
"first-tooltip": "the first name only", "first-tooltip": "the first name only",
"first-initial": "First name and initial", "first-initial": "First name and initial",
"first-initial-tooltip": "first name plus the first two initial letters of the last name", "first-initial-tooltip": "first name plus the first letter of the last name",
"initials": "Initials", "initials": "Initials",
"initials-tooltip": "Initials of first name and last name, i.e. the first two letters of each regardless of the existence of the user name", "initials-tooltip": "Initials of first name and last name, i.e. the first two letters of each regardless of the existence of the user name",
"name-full": "first name and last name", "name-full": "first name and last name",

View File

@ -170,7 +170,7 @@ describe('TransactionLink', () => {
}) })
}) })
describe('redeem link with success', () => { describe.skip('redeem link with success', () => {
let mockMutation let mockMutation
beforeEach(async () => { beforeEach(async () => {

View File

@ -1,9 +1,15 @@
<template> <template>
<div class="show-transaction-link-informations"> <div class="show-transaction-link-informations">
<div class="mt-4"> <div v-if="isTransactionLinkLoaded" class="mt-4">
<transaction-link-item :type="itemTypeExt"> <transaction-link-item :type="itemTypeExt">
<template #LOGGED_OUT> <template #REDEEM_SELECT_COMMUNITY>
<redeem-logged-out :link-data="linkData" :is-contribution-link="isContributionLink" /> <redeem-select-community
:link-data="linkData"
:redeem-code="redeemCode"
:is-transaction-link-loaded="isTransactionLinkLoaded"
:is-contribution-link="isContributionLink"
:is-redeem-jwt-link="isRedeemJwtLink"
/>
</template> </template>
<template #SELF_CREATOR> <template #SELF_CREATOR>
@ -14,6 +20,7 @@
<redeem-valid <redeem-valid
:link-data="linkData" :link-data="linkData"
:is-contribution-link="isContributionLink" :is-contribution-link="isContributionLink"
:is-redeem-jwt-link="isRedeemJwtLink"
:valid-link="validLink" :valid-link="validLink"
@mutation-link="mutationLink" @mutation-link="mutationLink"
/> />
@ -33,48 +40,69 @@ import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex' import { useStore } from 'vuex'
import { useQuery, useMutation } from '@vue/apollo-composable' import { useQuery, useMutation } from '@vue/apollo-composable'
import TransactionLinkItem from '@/components/TransactionLinkItem' import TransactionLinkItem from '@/components/TransactionLinkItem'
import RedeemLoggedOut from '@/components/LinkInformations/RedeemLoggedOut' import RedeemSelectCommunity from '@/components/LinkInformations/RedeemSelectCommunity'
import RedeemSelfCreator from '@/components/LinkInformations/RedeemSelfCreator' import RedeemSelfCreator from '@/components/LinkInformations/RedeemSelfCreator'
import RedeemValid from '@/components/LinkInformations/RedeemValid' import RedeemValid from '@/components/LinkInformations/RedeemValid'
import RedeemedTextBox from '@/components/LinkInformations/RedeemedTextBox' import RedeemedTextBox from '@/components/LinkInformations/RedeemedTextBox'
import { useAppToast } from '@/composables/useToast' import { useAppToast } from '@/composables/useToast'
import { queryTransactionLink } from '@/graphql/queries' import { queryTransactionLink } from '@/graphql/queries'
import { redeemTransactionLink } from '@/graphql/mutations' import { disburseTransactionLink, redeemTransactionLink } from '@/graphql/mutations'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { toastError, toastSuccess } = useAppToast() const { toastError, toastSuccess } = useAppToast()
const router = useRouter() const router = useRouter()
const { params } = useRoute() const { params, meta } = useRoute()
const store = useStore() const store = useStore()
const { d, t } = useI18n() const { d, t } = useI18n()
const isTransactionLinkLoaded = ref(false)
const linkData = ref({ const linkData = ref({
__typename: 'TransactionLink', __typename: 'TransactionLink',
amount: '', validUntil: null,
amount: 0,
memo: '', memo: '',
user: { senderCommunity: null,
firstName: '', senderUser: null,
}, recipientCommunity: null,
recipientUser: null,
deletedAt: null, deletedAt: null,
redeemedAt: null,
validLink: false, validLink: false,
communities: [],
// ContributionLink fields
validTo: null,
validFrom: null,
name: '',
cycle: null,
link: '',
maxAmountPerMonth: null,
}) })
const redeemedBoxText = ref('') const redeemedBoxText = ref('')
const { result, onResult, loading, error, onError } = useQuery(queryTransactionLink, { const { result, onResult, error, onError } = useQuery(queryTransactionLink, {
code: params.code, code: params.code,
}) })
const { const { mutate: redeemMutate } = useMutation(redeemTransactionLink)
mutate: redeemMutate, const { mutate: disburseMutate } = useMutation(disburseTransactionLink)
loading: redeemLoading,
error: redeemError,
} = useMutation(redeemTransactionLink)
const isContributionLink = computed(() => { const isContributionLink = computed(() => {
return params.code?.search(/^CL-/) === 0 return params.code?.search(/^CL-/) === 0
}) })
const isRedeemJwtLink = computed(() => {
if (
isTransactionLinkLoaded.value &&
result.value?.queryTransactionLink?.__typename === 'RedeemJwtLink'
) {
return true
}
return false
})
const redeemCode = computed(() => params.code)
const tokenExpiresInSeconds = computed(() => { const tokenExpiresInSeconds = computed(() => {
const remainingSecs = Math.floor( const remainingSecs = Math.floor(
(new Date(store.state.tokenTime * 1000).getTime() - new Date().getTime()) / 1000, (new Date(store.state.tokenTime * 1000).getTime() - new Date().getTime()) / 1000,
@ -83,25 +111,109 @@ const tokenExpiresInSeconds = computed(() => {
}) })
const validLink = computed(() => { const validLink = computed(() => {
return new Date(linkData.value.validUntil) > new Date() // console.log('TransactionLink.validLink... linkData.value.validUntil=', linkData.value.validUntil)
// console.log('TransactionLink.validLink... new Date()=', new Date())
if (!isTransactionLinkLoaded.value) {
return false
}
if (!linkData.value.validUntil) {
return false
}
const validUntilDate = new Date(linkData.value.validUntil)
// console.log('TransactionLink.validLink... validUntilDate=', validUntilDate)
// console.log('TransactionLink.validLink... new Date()=', new Date())
// console.log(
// 'TransactionLink.validLink... validUntilDate.getTime() >= new Date().getTime()=',
// validUntilDate.getTime() >= new Date().getTime(),
// )
return validUntilDate.getTime() >= new Date().getTime()
}) })
const itemType = computed(() => { const itemType = computed(() => {
if (linkData.value.deletedAt) return 'TEXT_DELETED' // console.log('TransactionLink.itemType... isTransactionLinkLoaded=', isTransactionLinkLoaded.value)
if (new Date(linkData.value.validUntil) < new Date()) return 'TEXT_EXPIRED' if (isTransactionLinkLoaded.value) {
if (linkData.value.redeemedAt) return 'TEXT_REDEEMED' // console.log('TransactionLink.itemType... linkData.value=', linkData.value)
if (linkData.value.deletedAt) {
// console.log('TransactionLink.itemType... TEXT_DELETED')
return 'TEXT_DELETED'
}
if (store.state.token && store.state.tokenTime) { const validUntilDate = new Date(linkData.value.validUntil)
if (tokenExpiresInSeconds.value < 5) return 'LOGGED_OUT' // console.log('TransactionLink.itemType... validUntilDate=', validUntilDate)
if (linkData.value.user && store.state.gradidoID === linkData.value.user.gradidoID) // console.log('TransactionLink.itemType... new Date()=', new Date())
// console.log(
// 'TransactionLink.itemType... validUntilDate.getTime() < new Date().getTime()=',
// validUntilDate.getTime() < new Date().getTime(),
// )
if (validUntilDate.getTime() < new Date().getTime()) {
// console.log('TransactionLink.itemType... TEXT_EXPIRED')
return 'TEXT_EXPIRED'
}
if (linkData.value.redeemedAt) {
// console.log('TransactionLink.itemType... TEXT_REDEEMED')
return 'TEXT_REDEEMED'
}
if (linkData.value.deletedAt) {
// console.log('TransactionLink.itemType... TEXT_DELETED')
return 'TEXT_DELETED'
}
if (store.state.token && store.state.tokenTime) {
if (tokenExpiresInSeconds.value < 5) {
// console.log('TransactionLink.itemType... REDEEM_SELECT_COMMUNITY')
return 'REDEEM_SELECT_COMMUNITY'
}
}
// console.log(
// 'TransactionLink.itemType... linkData.value.recipientUser=',
// linkData.value.recipientUser,
// )
// console.log('TransactionLink.itemType... linkData.value=', linkData.value)
// console.log('TransactionLink.itemType... store.state.gradidoID=', store.state.gradidoID)
// console.log('TransactionLink.itemType... isRedeemJwtLink=', isRedeemJwtLink.value)
// console.log('TransactionLink.itemType... linkData.value.senderUser=', linkData.value.senderUser)
// console.log(
// 'TransactionLink.itemType... linkData.value.recipientUser.gradidoID=',
// linkData.value.recipientUser?.gradidoID,
// )
// console.log(
// 'TransactionLink.itemType... linkData.value.senderUser.gradidoID=',
// linkData.value.senderUser?.gradidoID,
// )
if (
linkData.value.senderUser &&
linkData.value.recipientUser &&
linkData.value.senderUser.gradidoID === linkData.value.recipientUser.gradidoID
) {
// console.log('TransactionLink.itemType... SELF_CREATOR')
return 'SELF_CREATOR' return 'SELF_CREATOR'
if (!linkData.value.redeemedAt && !linkData.value.deletedAt) return 'VALID' }
if (
linkData.value.senderUser &&
linkData.value.recipientUser &&
linkData.value.senderUser.gradidoID !== linkData.value.recipientUser.gradidoID &&
store.state.gradidoID === linkData.value.recipientUser.gradidoID
) {
// console.log('TransactionLink.itemType... VALID')
// console.log('TransactionLink.itemType... linkData.value=', linkData.value)
// console.log('TransactionLink.itemType... store.state.gradidoID=', store.state.gradidoID)
// console.log(
// 'TransactionLink.itemType... linkData.value.recipientUser.gradidoID=',
// linkData.value.recipientUser.gradidoID,
// )
// console.log(
// 'TransactionLink.itemType... linkData.value.senderUser.gradidoID=',
// linkData.value.senderUser.gradidoID,
// )
return 'VALID'
}
} }
// console.log('TransactionLink.itemType...last return= REDEEM_SELECT_COMMUNITY')
return 'LOGGED_OUT' return 'REDEEM_SELECT_COMMUNITY'
}) })
const itemTypeExt = computed(() => { const itemTypeExt = computed(() => {
// console.log('TransactionLink.itemTypeExt... itemType=', itemType.value)
// console.log('TransactionLink.itemTypeExt... validLink=', validLink.value)
if (itemType.value.startsWith('TEXT')) { if (itemType.value.startsWith('TEXT')) {
return 'TEXT' return 'TEXT'
} }
@ -109,10 +221,13 @@ const itemTypeExt = computed(() => {
}) })
watch(itemType, (newItemType) => { watch(itemType, (newItemType) => {
// console.log('TransactionLink.watch... itemType=', itemType.value)
// console.log('TransactionLink.watch... validLink=', validLink.value)
updateRedeemedBoxText(newItemType) updateRedeemedBoxText(newItemType)
}) })
function updateRedeemedBoxText(type) { function updateRedeemedBoxText(type) {
// console.log('TransactionLink.updateRedeemedBoxText... type=', type)
switch (type) { switch (type) {
case 'TEXT_DELETED': case 'TEXT_DELETED':
redeemedBoxText.value = t('gdd_per_link.link-deleted', { redeemedBoxText.value = t('gdd_per_link.link-deleted', {
@ -132,43 +247,124 @@ function updateRedeemedBoxText(type) {
default: default:
redeemedBoxText.value = '' redeemedBoxText.value = ''
} }
// console.log('TransactionLink.updateRedeemedBoxText... redeemedBoxText=', redeemedBoxText)
} }
const emit = defineEmits(['set-mobile-start']) const emit = defineEmits(['set-mobile-start'])
onMounted(() => { onMounted(() => {
// console.log('TransactionLink.onMounted... params=', params)
emit('set-mobile-start', false) emit('set-mobile-start', false)
}) })
onResult(() => { onResult(() => {
if (!result || !result.value) return // console.log('TransactionLink.onResult... result=', result.value)
setTransactionLinkInformation() // console.log('TransactionLink.onResult... stringify result=', JSON.stringify(result.value))
if (result.value?.queryTransactionLink?.__typename === 'TransactionLink') {
// console.log('TransactionLink.onResult... TransactionLink')
isTransactionLinkLoaded.value = true
setTransactionLinkInformation()
} else if (result.value?.queryTransactionLink?.__typename === 'RedeemJwtLink') {
// console.log('TransactionLink.onResult... RedeemJwtLink')
isTransactionLinkLoaded.value = true
setRedeemJwtLinkInformation()
} else {
// console.log('TransactionLink.onResult... unknown type:', result.value)
}
}) })
onError(() => { onError(() => {
// console.log('TransactionLink.onError... error=', error)
toastError(t('gdd_per_link.redeemlink-error')) toastError(t('gdd_per_link.redeemlink-error'))
}) })
function setTransactionLinkInformation() { function setTransactionLinkInformation() {
const { queryTransactionLink } = result.value // console.log('TransactionLink.setTransactionLinkInformation... result=', result.value)
if (queryTransactionLink) { // const queryTransactionLink = result.value.queryTransactionLink
linkData.value = queryTransactionLink const deepCopy = JSON.parse(JSON.stringify(result.value))
// console.log('TransactionLink.setTransactionLinkInformation... deepCopy=', deepCopy)
if (deepCopy && deepCopy.queryTransactionLink.__typename === 'TransactionLink') {
// console.log('TransactionLink.setTransactionLinkInformation... typename === TransactionLink')
// recipientUser is only set if the user is logged in
if (store.state.gradidoID !== null) {
// console.log(
// 'TransactionLink.setTransactionLinkInformation... gradidoID=',
// store.state.gradidoID,
// )
deepCopy.queryTransactionLink.recipientUser = {
__typename: 'User',
gradidoID: store.state.gradidoID,
firstName: store.state.firstName,
alias: store.state.alias,
}
// console.log(
// 'TransactionLink.setTransactionLinkInformation... deepCopy.queryTransactionLink.recipientUser=',
// deepCopy.queryTransactionLink.recipientUser,
// )
}
linkData.value = deepCopy.queryTransactionLink
// console.log('TransactionLink.setTransactionLinkInformation... linkData.value=', linkData.value)
if (linkData.value.__typename === 'ContributionLink' && store.state.token) { if (linkData.value.__typename === 'ContributionLink' && store.state.token) {
// console.log('TransactionLink.setTransactionLinkInformation... typename === ContributionLink')
// explicit no await
mutationLink(linkData.value.amount) mutationLink(linkData.value.amount)
} }
} }
} }
function setRedeemJwtLinkInformation() {
// console.log('TransactionLink.setRedeemJwtLinkInformation... result=', result.value)
const deepCopy = JSON.parse(JSON.stringify(result.value))
// console.log('TransactionLink.setRedeemJwtLinkInformation... deepCopy=', deepCopy)
if (deepCopy) {
// recipientUser is only set if the user is logged in
if (store.state.gradidoID !== null) {
// console.log(
// 'TransactionLink.setRedeemJwtLinkInformation... gradidoID=',
// store.state.gradidoID,
// )
deepCopy.queryTransactionLink.recipientUser = {
__typename: 'User',
gradidoID: store.state.gradidoID,
firstName: store.state.firstName,
alias: store.state.alias,
}
}
// console.log(
// 'TransactionLink.setRedeemJwtLinkInformation... deepCopy.queryTransactionLink.recipientUser=',
// deepCopy.queryTransactionLink.recipientUser,
// )
linkData.value = deepCopy.queryTransactionLink
// console.log('TransactionLink.setRedeemJwtLinkInformation... linkData.value=', linkData.value)
}
}
async function mutationLink(amount) { async function mutationLink(amount) {
try { // console.log('TransactionLink.mutationLink... params=', params)
await redeemMutate({ if (isRedeemJwtLink.value) {
code: params.code, // console.log('TransactionLink.mutationLink... trigger disbursement from recipient-community')
}) try {
toastSuccess(t('gdd_per_link.redeemed', { n: amount })) await disburseMutate({
await router.push('/overview') code: params.code,
} catch (err) { })
toastError(err.message) toastSuccess(t('gdd_per_link.disbured', { n: amount }))
await router.push('/overview') await router.push('/overview')
} catch (err) {
toastError(err.message)
await router.push('/overview')
}
} else {
// console.log('TransactionLink.mutationLink... local transaction or contribution')
try {
await redeemMutate({
code: redeemCode.value,
})
toastSuccess(t('gdd_per_link.redeemed', { n: amount }))
await router.push('/overview')
} catch (err) {
toastError(err.message)
await router.push('/overview')
}
} }
} }
</script> </script>

View File

@ -0,0 +1,174 @@
<template>
<div class="show-transaction-link-informations">
<div class="mt-4">
<transaction-link-item :type="itemTypeExt">
<template #LOGGED_OUT>
<redeem-logged-out :link-data="linkData" :is-contribution-link="isContributionLink" />
</template>
<template #SELF_CREATOR>
<redeem-self-creator :link-data="linkData" />
</template>
<template #VALID>
<redeem-valid
:link-data="linkData"
:is-contribution-link="isContributionLink"
:valid-link="validLink"
@mutation-link="mutationLink"
/>
</template>
<template #TEXT>
<redeemed-text-box :text="redeemedBoxText" />
</template>
</transaction-link-item>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { useQuery, useMutation } from '@vue/apollo-composable'
import TransactionLinkItem from '@/components/TransactionLinkItem'
import RedeemLoggedOut from '@/components/LinkInformations/RedeemLoggedOut'
import RedeemSelfCreator from '@/components/LinkInformations/RedeemSelfCreator'
import RedeemValid from '@/components/LinkInformations/RedeemValid'
import RedeemedTextBox from '@/components/LinkInformations/RedeemedTextBox'
import { useAppToast } from '@/composables/useToast'
import { queryTransactionLink } from '@/graphql/queries'
import { redeemTransactionLink } from '@/graphql/mutations'
import { useI18n } from 'vue-i18n'
const { toastError, toastSuccess } = useAppToast()
const router = useRouter()
const { params } = useRoute()
const store = useStore()
const { d, t } = useI18n()
const linkData = ref({
__typename: 'TransactionLink',
amount: '',
memo: '',
user: {
firstName: '',
},
deletedAt: null,
validLink: false,
})
const redeemedBoxText = ref('')
const { result, onResult, loading, error, onError } = useQuery(queryTransactionLink, {
code: params.code,
})
const {
mutate: redeemMutate,
loading: redeemLoading,
error: redeemError,
} = useMutation(redeemTransactionLink)
const isContributionLink = computed(() => {
return params.code?.search(/^CL-/) === 0
})
const tokenExpiresInSeconds = computed(() => {
const remainingSecs = Math.floor(
(new Date(store.state.tokenTime * 1000).getTime() - new Date().getTime()) / 1000,
)
return remainingSecs <= 0 ? 0 : remainingSecs
})
const validLink = computed(() => {
return new Date(linkData.value.validUntil) > new Date()
})
const itemType = computed(() => {
if (linkData.value.deletedAt) return 'TEXT_DELETED'
if (new Date(linkData.value.validUntil) < new Date()) return 'TEXT_EXPIRED'
if (linkData.value.redeemedAt) return 'TEXT_REDEEMED'
if (store.state.token && store.state.tokenTime) {
if (tokenExpiresInSeconds.value < 5) return 'LOGGED_OUT'
if (linkData.value.user && store.state.gradidoID === linkData.value.user.gradidoID)
return 'SELF_CREATOR'
if (!linkData.value.redeemedAt && !linkData.value.deletedAt) return 'VALID'
}
return 'LOGGED_OUT'
})
const itemTypeExt = computed(() => {
if (itemType.value.startsWith('TEXT')) {
return 'TEXT'
}
return itemType.value
})
watch(itemType, (newItemType) => {
updateRedeemedBoxText(newItemType)
})
function updateRedeemedBoxText(type) {
switch (type) {
case 'TEXT_DELETED':
redeemedBoxText.value = t('gdd_per_link.link-deleted', {
date: d(new Date(linkData.value.deletedAt), 'long'),
})
break
case 'TEXT_EXPIRED':
redeemedBoxText.value = t('gdd_per_link.link-expired', {
date: d(new Date(linkData.value.validUntil), 'long'),
})
break
case 'TEXT_REDEEMED':
redeemedBoxText.value = t('gdd_per_link.redeemed-at', {
date: d(new Date(linkData.value.redeemedAt), 'long'),
})
break
default:
redeemedBoxText.value = ''
}
}
const emit = defineEmits(['set-mobile-start'])
onMounted(() => {
emit('set-mobile-start', false)
})
onResult(() => {
if (!result || !result.value) return
setTransactionLinkInformation()
})
onError(() => {
toastError(t('gdd_per_link.redeemlink-error'))
})
function setTransactionLinkInformation() {
const { queryTransactionLink } = result.value
if (queryTransactionLink) {
linkData.value = queryTransactionLink
if (linkData.value.__typename === 'ContributionLink' && store.state.token) {
mutationLink(linkData.value.amount)
}
}
}
async function mutationLink(amount) {
try {
await redeemMutate({
code: params.code,
})
toastSuccess(t('gdd_per_link.redeemed', { n: amount }))
await router.push('/overview')
} catch (err) {
toastError(err.message)
await router.push('/overview')
}
}
</script>