Merge branch 'master' into use_bun_instead_of_yarn

This commit is contained in:
einhornimmond 2025-10-09 07:18:55 +02:00 committed by GitHub
commit c5adb12977
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 917 additions and 658 deletions

View File

@ -15,7 +15,11 @@
</div>
<div ref="chatContainer" class="messages-scroll-container">
<TransitionGroup class="messages" tag="div" name="chat">
<div v-for="(message, index) in messages" :key="index" :class="['message', message.role]">
<div
v-for="(message, index) in messages"
:key="index"
:class="['message', message.role, { 'message-error': message.isError }]"
>
<div class="message-content position-relative inner-container">
<span v-html="formatMessage(message)"></span>
<b-button
@ -170,7 +174,18 @@ const sendMessage = () => {
onMounted(async () => {
if (messages.value.length === 0) {
loading.value = true
await resumeChatRefetch()
try {
await resumeChatRefetch()
} catch (error) {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
toastError(`Error loading chat: ${error.graphQLErrors[0].message}`)
return
} else {
// eslint-disable-next-line no-console
console.log(JSON.stringify(error, null, 2))
toastError(`Error loading chat: ${error}`)
}
}
const messagesFromServer = resumeChatResult.value.resumeChat
if (messagesFromServer && messagesFromServer.length > 0) {
threadId.value = messagesFromServer[0].threadId
@ -279,6 +294,17 @@ onMounted(async () => {
margin-right: auto;
}
.message.error {
text-align: center;
}
.message.error .message-content {
background-color: #f1e5e5;
color: rgb(194 12 12);
margin-left: auto;
margin-right: auto;
}
.input-area {
display: flex;
padding: 10px;

View File

@ -26,6 +26,7 @@ fragment AiChatMessageFields on ChatGptMessage {
content
role
threadId
isError
}
fragment UserCommonFields on User {

View File

@ -8,7 +8,7 @@ export const updateHomeCommunity = gql`
location: $location
hieroTopicId: $hieroTopicId
) {
id
uuid
}
}
`

View File

@ -11,6 +11,8 @@ import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { getLogger } from 'log4js'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.apis.openai.OpenaiClient`)
// this is the time after when openai is deleting an inactive thread
const OPENAI_AI_THREAD_DEFAULT_TIMEOUT_DAYS = 60
/**
* The `OpenaiClient` class is a singleton that provides an interface to interact with the OpenAI API.
@ -87,21 +89,35 @@ export class OpenaiClient {
logger.warn(`No openai thread found for user: ${user.id}`)
return []
}
const threadMessages = (
await this.openai.beta.threads.messages.list(openaiThreadEntity.id, { order: 'desc' })
).getPaginatedItems()
if (openaiThreadEntity.updatedAt < new Date(Date.now() - OPENAI_AI_THREAD_DEFAULT_TIMEOUT_DAYS * 24 * 60 * 60 * 1000)) {
logger.info(`Openai thread for user: ${user.id} is older than ${OPENAI_AI_THREAD_DEFAULT_TIMEOUT_DAYS} days, deleting...`)
// let run async, because it could need some time, but we don't need to wait, because we create a new one nevertheless
void this.deleteThread(openaiThreadEntity.id)
return []
}
try {
const threadMessages = (
await this.openai.beta.threads.messages.list(openaiThreadEntity.id, { order: 'desc' })
).getPaginatedItems()
logger.info(`Resumed thread: ${openaiThreadEntity.id}`)
return threadMessages
.map(
(message) =>
new MessageModel(
this.messageContentToString(message),
message.role,
openaiThreadEntity.id,
),
)
.reverse()
logger.info(`Resumed thread: ${openaiThreadEntity.id}`)
return threadMessages
.map(
(message) =>
new MessageModel(
this.messageContentToString(message),
message.role,
openaiThreadEntity.id,
),
)
.reverse()
} catch (e) {
if(e instanceof Error && e.toString().includes('No thread found with id')) {
logger.info(`Thread not found: ${openaiThreadEntity.id}`)
return []
}
throw e
}
}
public async deleteThread(threadId: string): Promise<boolean> {
@ -124,6 +140,7 @@ export class OpenaiClient {
}
public async runAndGetLastNewMessage(threadId: string): Promise<MessageModel> {
const updateOpenAiThreadResolver = OpenaiThreads.update({ id: threadId }, { updatedAt: new Date() })
const run = await this.openai.beta.threads.runs.createAndPoll(threadId, {
assistant_id: CONFIG.OPENAI_ASSISTANT_ID,
})
@ -138,6 +155,7 @@ export class OpenaiClient {
logger.warn(`No message in thread: ${threadId}, run: ${run.id}`, messagesPage.data)
return new MessageModel('No Answer', 'assistant')
}
await updateOpenAiThreadResolver
return new MessageModel(this.messageContentToString(message), 'assistant')
}

View File

@ -5,8 +5,6 @@ export const ADMIN_RIGHTS = [
RIGHTS.DELETE_USER,
RIGHTS.UNDELETE_USER,
RIGHTS.COMMUNITY_UPDATE,
RIGHTS.COMMUNITY_BY_UUID,
RIGHTS.COMMUNITY_BY_IDENTIFIER,
RIGHTS.HOME_COMMUNITY,
RIGHTS.COMMUNITY_WITH_API_KEYS,
RIGHTS.PROJECT_BRANDING_MUTATE,
]

View File

@ -1,3 +1,3 @@
import { RIGHTS } from './RIGHTS'
export const DLT_CONNECTOR_RIGHTS = [RIGHTS.COMMUNITY_BY_IDENTIFIER, RIGHTS.HOME_COMMUNITY]
export const DLT_CONNECTOR_RIGHTS = [RIGHTS.COMMUNITIES, RIGHTS.COMMUNITY_UPDATE]

View File

@ -69,9 +69,7 @@ export enum RIGHTS {
SET_USER_ROLE = 'SET_USER_ROLE',
DELETE_USER = 'DELETE_USER',
UNDELETE_USER = 'UNDELETE_USER',
COMMUNITY_BY_UUID = 'COMMUNITY_BY_UUID',
COMMUNITY_BY_IDENTIFIER = 'COMMUNITY_BY_IDENTIFIER',
HOME_COMMUNITY = 'HOME_COMMUNITY',
COMMUNITY_UPDATE = 'COMMUNITY_UPDATE',
COMMUNITY_WITH_API_KEYS = 'COMMUNITY_WITH_API_KEYS',
PROJECT_BRANDING_MUTATE = 'PROJECT_BRANDING_MUTATE',
}

View File

@ -7,7 +7,7 @@ export class UserArgs {
@IsString()
identifier: string
@Field()
@Field({ nullable: true })
@IsString()
communityIdentifier: string
communityIdentifier?: string
}

View File

@ -38,7 +38,6 @@ export class AdminCommunityView {
this.updatedAt = dbCom.updatedAt
this.uuid = dbCom.communityUuid
this.authenticatedAt = dbCom.authenticatedAt
this.gmsApiKey = dbCom.gmsApiKey
this.hieroTopicId = dbCom.hieroTopicId
if (dbCom.location) {
this.location = Point2Location(dbCom.location as Point)

View File

@ -10,10 +10,14 @@ export class ChatGptMessage {
@Field()
role: string
@Field()
threadId: string
@Field({ nullable: true })
threadId?: string
public constructor(data: Partial<Message>) {
@Field()
isError: boolean
public constructor(data: Partial<Message>, isError: boolean = false) {
Object.assign(this, data)
this.isError = isError
}
}

View File

@ -12,7 +12,6 @@ export class Community {
this.creationDate = dbCom.creationDate
this.uuid = dbCom.communityUuid
this.authenticatedAt = dbCom.authenticatedAt
this.gmsApiKey = dbCom.gmsApiKey
this.hieroTopicId = dbCom.hieroTopicId
}
@ -40,9 +39,6 @@ export class Community {
@Field(() => Date, { nullable: true })
authenticatedAt: Date | null
@Field(() => String, { nullable: true })
gmsApiKey: string | null
@Field(() => String, { nullable: true })
hieroTopicId: string | null
}

View File

@ -15,10 +15,10 @@ export class AiChatResolver {
async resumeChat(@Ctx() context: Context): Promise<ChatGptMessage[]> {
const openaiClient = OpenaiClient.getInstance()
if (!openaiClient) {
return Promise.resolve([new ChatGptMessage({ content: 'OpenAI API is not enabled' })])
return Promise.resolve([new ChatGptMessage({ content: 'OpenAI API is not enabled', role: 'assistant' }, true)])
}
if (!context.user) {
return Promise.resolve([new ChatGptMessage({ content: 'User not found' })])
return Promise.resolve([new ChatGptMessage({ content: 'User not found', role: 'assistant' }, true)])
}
const messages = await openaiClient.resumeThread(context.user)
return messages.map((message) => new ChatGptMessage(message))
@ -42,10 +42,10 @@ export class AiChatResolver {
): Promise<ChatGptMessage> {
const openaiClient = OpenaiClient.getInstance()
if (!openaiClient) {
return Promise.resolve(new ChatGptMessage({ content: 'OpenAI API is not enabled' }))
return Promise.resolve(new ChatGptMessage({ content: 'OpenAI API is not enabled', role: 'assistant' }, true))
}
if (!context.user) {
return Promise.resolve(new ChatGptMessage({ content: 'User not found' }))
return Promise.resolve(new ChatGptMessage({ content: 'User not found', role: 'assistant' }, true))
}
const messageObj = new Message(message)
if (!threadId || threadId.length === 0) {

View File

@ -1,5 +1,5 @@
import { ApolloServerTestClient } from 'apollo-server-testing'
import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity } from 'database'
import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity, getHomeCommunity } from 'database'
import { GraphQLError } from 'graphql/error/GraphQLError'
import { DataSource } from 'typeorm'
import { v4 as uuidv4 } from 'uuid'
@ -10,19 +10,20 @@ import { i18n as localization } from '@test/testSetup'
import { userFactory } from '@/seeds/factory/user'
import { login, updateHomeCommunityQuery } from '@/seeds/graphql/mutations'
import {
allCommunities,
communitiesQuery,
getCommunities,
allCommunities,
getCommunityByIdentifierQuery,
getHomeCommunityQuery,
reachableCommunities,
} from '@/seeds/graphql/queries'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { createCommunity, createVerifiedFederatedCommunity } from 'database/src/seeds/community'
import { getLogger } from 'config-schema/test/testSetup'
import { getCommunityByUuid } from './util/communities'
import { CONFIG } from '@/config'
jest.mock('@/password/EncryptorUtils')
CONFIG.FEDERATION_VALIDATE_COMMUNITY_TIMER = 1000
// to do: We need a setup for the tests that closes the connection
let mutate: ApolloServerTestClient['mutate']
@ -46,11 +47,10 @@ beforeAll(async () => {
mutate = testEnv.mutate
query = testEnv.query
con = testEnv.con
await DbFederatedCommunity.clear()
await cleanDB()
})
afterAll(async () => {
await cleanDB()
await con.destroy()
})
@ -109,31 +109,38 @@ const ed25519KeyPairStaticHex = [
]
describe('CommunityResolver', () => {
describe('getCommunities', () => {
describe('allCommunities for admin', () => {
let homeCom1: DbFederatedCommunity
let homeCom2: DbFederatedCommunity
let homeCom3: DbFederatedCommunity
let foreignCom1: DbFederatedCommunity
let foreignCom2: DbFederatedCommunity
let foreignCom3: DbFederatedCommunity
let foreignCom3: DbFederatedCommunity
beforeAll(async () => {
// create admin and login as admin
await userFactory(testEnv, peterLustig)
await mutate({ mutation: login, variables: peterLoginData })
})
afterAll(async () => {
await cleanDB()
})
describe('with empty list', () => {
it('returns no community entry', async () => {
// const result: Community[] = await query({ query: getCommunities })
// expect(result.length).toEqual(0)
await expect(query({ query: getCommunities })).resolves.toMatchObject({
await expect(query({ query: allCommunities })).resolves.toMatchObject({
data: {
getCommunities: [],
allCommunities: [],
},
})
})
})
describe('only home-communities entries', () => {
describe('only home-community entries (different apis)', () => {
beforeEach(async () => {
await cleanDB()
jest.clearAllMocks()
homeCom1 = DbFederatedCommunity.create()
homeCom1.foreign = false
homeCom1.publicKey = Buffer.from(ed25519KeyPairStaticHex[0].public, 'hex')
@ -144,7 +151,7 @@ describe('CommunityResolver', () => {
homeCom2 = DbFederatedCommunity.create()
homeCom2.foreign = false
homeCom2.publicKey = Buffer.from(ed25519KeyPairStaticHex[1].public, 'hex')
homeCom2.publicKey = Buffer.from(ed25519KeyPairStaticHex[0].public, 'hex')
homeCom2.apiVersion = '1_1'
homeCom2.endPoint = 'http://localhost/api'
homeCom2.createdAt = new Date()
@ -152,170 +159,67 @@ describe('CommunityResolver', () => {
homeCom3 = DbFederatedCommunity.create()
homeCom3.foreign = false
homeCom3.publicKey = Buffer.from(ed25519KeyPairStaticHex[2].public, 'hex')
homeCom3.publicKey = Buffer.from(ed25519KeyPairStaticHex[0].public, 'hex')
homeCom3.apiVersion = '2_0'
homeCom3.endPoint = 'http://localhost/api'
homeCom3.createdAt = new Date()
await DbFederatedCommunity.insert(homeCom3)
})
it('returns 3 home-community entries', async () => {
await expect(query({ query: getCommunities })).resolves.toMatchObject({
it('returns only home-community entries', async () => {
await expect(query({ query: allCommunities })).resolves.toMatchObject({
data: {
getCommunities: [
allCommunities: [
{
id: 3,
foreign: homeCom3.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[2].public),
endPoint: expect.stringMatching('http://localhost/api/'),
apiVersion: '2_0',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
createdAt: homeCom3.createdAt.toISOString(),
updatedAt: null,
},
{
id: 2,
foreign: homeCom2.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[1].public),
endPoint: expect.stringMatching('http://localhost/api/'),
apiVersion: '1_1',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
createdAt: homeCom2.createdAt.toISOString(),
updatedAt: null,
},
{
id: 1,
foreign: homeCom1.foreign,
foreign: false,
url: 'http://localhost',
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[0].public),
endPoint: expect.stringMatching('http://localhost/api/'),
apiVersion: '1_0',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
createdAt: homeCom1.createdAt.toISOString(),
authenticatedAt: null,
createdAt: null,
creationDate: null,
description: null,
gmsApiKey: null,
name: null,
updatedAt: null,
uuid: null,
federatedCommunities: [
{
id: 3,
apiVersion: '2_0',
endPoint: 'http://localhost/api/',
createdAt: homeCom3.createdAt.toISOString(),
lastAnnouncedAt: null,
lastErrorAt: null,
updatedAt: null,
verifiedAt: null,
},
{
id: 2,
apiVersion: '1_1',
endPoint: 'http://localhost/api/',
createdAt: homeCom2.createdAt.toISOString(),
lastAnnouncedAt: null,
lastErrorAt: null,
updatedAt: null,
verifiedAt: null,
},
{
id: 1,
apiVersion: '1_0',
endPoint: 'http://localhost/api/',
createdAt: homeCom1.createdAt.toISOString(),
lastAnnouncedAt: null,
lastErrorAt: null,
updatedAt: null,
verifiedAt: null,
},
],
},
],
},
})
})
})
describe('plus foreign-communities entries', () => {
beforeEach(async () => {
jest.clearAllMocks()
foreignCom1 = DbFederatedCommunity.create()
foreignCom1.foreign = true
foreignCom1.publicKey = Buffer.from(ed25519KeyPairStaticHex[3].public, 'hex')
foreignCom1.apiVersion = '1_0'
foreignCom1.endPoint = 'http://remotehost/api'
foreignCom1.createdAt = new Date()
await DbFederatedCommunity.insert(foreignCom1)
foreignCom2 = DbFederatedCommunity.create()
foreignCom2.foreign = true
foreignCom2.publicKey = Buffer.from(ed25519KeyPairStaticHex[4].public, 'hex')
foreignCom2.apiVersion = '1_1'
foreignCom2.endPoint = 'http://remotehost/api'
foreignCom2.createdAt = new Date()
await DbFederatedCommunity.insert(foreignCom2)
foreignCom3 = DbFederatedCommunity.create()
foreignCom3.foreign = true
foreignCom3.publicKey = Buffer.from(ed25519KeyPairStaticHex[5].public, 'hex')
foreignCom3.apiVersion = '2_0'
foreignCom3.endPoint = 'http://remotehost/api'
foreignCom3.createdAt = new Date()
await DbFederatedCommunity.insert(foreignCom3)
})
it('returns 3 home community and 3 foreign community entries', async () => {
await expect(query({ query: getCommunities })).resolves.toMatchObject({
data: {
getCommunities: [
{
id: 3,
foreign: homeCom3.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[2].public),
endPoint: expect.stringMatching('http://localhost/api/'),
apiVersion: '2_0',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
createdAt: homeCom3.createdAt.toISOString(),
updatedAt: null,
},
{
id: 2,
foreign: homeCom2.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[1].public),
endPoint: expect.stringMatching('http://localhost/api/'),
apiVersion: '1_1',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
createdAt: homeCom2.createdAt.toISOString(),
updatedAt: null,
},
{
id: 1,
foreign: homeCom1.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[0].public),
endPoint: expect.stringMatching('http://localhost/api/'),
apiVersion: '1_0',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
createdAt: homeCom1.createdAt.toISOString(),
updatedAt: null,
},
{
id: 6,
foreign: foreignCom3.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[5].public),
endPoint: expect.stringMatching('http://remotehost/api/'),
apiVersion: '2_0',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
createdAt: foreignCom3.createdAt.toISOString(),
updatedAt: null,
},
{
id: 5,
foreign: foreignCom2.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[4].public),
endPoint: expect.stringMatching('http://remotehost/api/'),
apiVersion: '1_1',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
createdAt: foreignCom2.createdAt.toISOString(),
updatedAt: null,
},
{
id: 4,
foreign: foreignCom1.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[3].public),
endPoint: expect.stringMatching('http://remotehost/api/'),
apiVersion: '1_0',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
createdAt: foreignCom1.createdAt.toISOString(),
updatedAt: null,
},
],
},
})
})
})
describe('with 6 federated community entries', () => {
let comHomeCom1: DbCommunity
let comForeignCom1: DbCommunity
@ -323,7 +227,6 @@ describe('CommunityResolver', () => {
let foreignCom4: DbFederatedCommunity
beforeEach(async () => {
jest.clearAllMocks()
comHomeCom1 = DbCommunity.create()
comHomeCom1.foreign = false
comHomeCom1.url = 'http://localhost'
@ -360,6 +263,30 @@ describe('CommunityResolver', () => {
comForeignCom2.creationDate = new Date()
await DbCommunity.insert(comForeignCom2)
foreignCom1 = DbFederatedCommunity.create()
foreignCom1.foreign = true
foreignCom1.publicKey = Buffer.from(ed25519KeyPairStaticHex[3].public, 'hex')
foreignCom1.apiVersion = '1_0'
foreignCom1.endPoint = 'http://remotehost/api'
foreignCom1.createdAt = new Date()
await DbFederatedCommunity.insert(foreignCom1)
foreignCom2 = DbFederatedCommunity.create()
foreignCom2.foreign = true
foreignCom2.publicKey = Buffer.from(ed25519KeyPairStaticHex[4].public, 'hex')
foreignCom2.apiVersion = '1_1'
foreignCom2.endPoint = 'http://remotehost/api'
foreignCom2.createdAt = new Date()
await DbFederatedCommunity.insert(foreignCom2)
foreignCom3 = DbFederatedCommunity.create()
foreignCom3.foreign = true
foreignCom3.publicKey = Buffer.from(ed25519KeyPairStaticHex[5].public, 'hex')
foreignCom3.apiVersion = '2_0'
foreignCom3.endPoint = 'http://remotehost/api'
foreignCom3.createdAt = new Date()
await DbFederatedCommunity.insert(foreignCom3)
foreignCom4 = DbFederatedCommunity.create()
foreignCom4.foreign = true
foreignCom4.publicKey = Buffer.from(ed25519KeyPairStaticHex[5].public, 'hex')
@ -376,15 +303,15 @@ describe('CommunityResolver', () => {
{
foreign: false,
url: 'http://localhost',
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[2].public),
authenticatedAt: null,
createdAt: null,
creationDate: null,
description: null,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[0].public),
authenticatedAt: comHomeCom1.authenticatedAt?.toISOString(),
createdAt: comHomeCom1.createdAt.toISOString(),
creationDate: comHomeCom1.creationDate?.toISOString(),
description: comHomeCom1.description,
gmsApiKey: null,
name: null,
name: comHomeCom1.name,
updatedAt: null,
uuid: null,
uuid: comHomeCom1.communityUuid,
federatedCommunities: [
{
id: 3,
@ -396,21 +323,6 @@ describe('CommunityResolver', () => {
updatedAt: null,
verifiedAt: null,
},
],
},
{
foreign: false,
url: 'http://localhost',
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[1].public),
authenticatedAt: null,
createdAt: null,
creationDate: null,
description: null,
gmsApiKey: null,
name: null,
updatedAt: null,
uuid: null,
federatedCommunities: [
{
id: 2,
apiVersion: '1_1',
@ -421,21 +333,6 @@ describe('CommunityResolver', () => {
updatedAt: null,
verifiedAt: null,
},
],
},
{
foreign: false,
url: 'http://localhost',
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[0].public),
authenticatedAt: comHomeCom1.authenticatedAt?.toISOString(),
createdAt: comHomeCom1.createdAt.toISOString(),
creationDate: comHomeCom1.creationDate?.toISOString(),
description: comHomeCom1.description,
gmsApiKey: null,
name: comHomeCom1.name,
updatedAt: null,
uuid: comHomeCom1.communityUuid,
federatedCommunities: [
{
id: 1,
apiVersion: '1_0',
@ -540,23 +437,21 @@ describe('CommunityResolver', () => {
})
})
describe('communities', () => {
describe('reachableCommunities', () => {
let homeCom1: DbCommunity
let foreignCom1: DbCommunity
let foreignCom2: DbCommunity
describe('with empty list', () => {
beforeEach(async () => {
await cleanDB()
jest.clearAllMocks()
})
afterAll(async () => {
await DbCommunity.clear()
await DbFederatedCommunity.clear()
})
describe('with empty list', () => {
it('returns no community entry', async () => {
// const result: Community[] = await query({ query: getCommunities })
// expect(result.length).toEqual(0)
await expect(query({ query: communitiesQuery })).resolves.toMatchObject({
await expect(query({ query: reachableCommunities })).resolves.toMatchObject({
data: {
communities: [],
reachableCommunities: [],
},
})
})
@ -564,35 +459,20 @@ describe('CommunityResolver', () => {
describe('with one home-community entry', () => {
beforeEach(async () => {
await cleanDB()
jest.clearAllMocks()
homeCom1 = DbCommunity.create()
homeCom1.foreign = false
homeCom1.url = 'http://localhost/api'
homeCom1.publicKey = Buffer.from(ed25519KeyPairStaticHex[0].public, 'hex')
homeCom1.privateKey = Buffer.from(ed25519KeyPairStaticHex[0].private, 'hex')
homeCom1.communityUuid = 'HomeCom-UUID'
homeCom1.authenticatedAt = new Date()
homeCom1.name = 'HomeCommunity-name'
homeCom1.description = 'HomeCommunity-description'
homeCom1.creationDate = new Date()
homeCom1 = await createCommunity(false, false)
await DbCommunity.insert(homeCom1)
})
it('returns 1 home-community entry', async () => {
await expect(query({ query: communitiesQuery })).resolves.toMatchObject({
await expect(query({ query: reachableCommunities })).resolves.toMatchObject({
data: {
communities: [
reachableCommunities: [
{
id: expect.any(Number),
foreign: homeCom1.foreign,
name: homeCom1.name,
description: homeCom1.description,
url: homeCom1.url,
creationDate: homeCom1.creationDate?.toISOString(),
uuid: homeCom1.communityUuid,
authenticatedAt: homeCom1.authenticatedAt?.toISOString(),
},
],
},
@ -602,135 +482,55 @@ describe('CommunityResolver', () => {
describe('returns 2 filtered communities even with 3 existing entries', () => {
beforeEach(async () => {
await cleanDB()
jest.clearAllMocks()
homeCom1 = DbCommunity.create()
homeCom1.foreign = false
homeCom1.url = 'http://localhost/api'
homeCom1.publicKey = Buffer.from(ed25519KeyPairStaticHex[0].public, 'hex')
homeCom1.privateKey = Buffer.from(ed25519KeyPairStaticHex[0].private, 'hex')
homeCom1.communityUuid = 'HomeCom-UUID'
homeCom1.authenticatedAt = new Date()
homeCom1.name = 'HomeCommunity-name'
homeCom1.description = 'HomeCommunity-description'
homeCom1.creationDate = new Date()
await DbCommunity.insert(homeCom1)
foreignCom1 = DbCommunity.create()
foreignCom1.foreign = true
foreignCom1.url = 'http://stage-2.gradido.net/api'
foreignCom1.publicKey = Buffer.from(ed25519KeyPairStaticHex[3].public, 'hex')
foreignCom1.privateKey = Buffer.from(ed25519KeyPairStaticHex[3].private, 'hex')
// foreignCom1.communityUuid = 'Stage2-Com-UUID'
// foreignCom1.authenticatedAt = new Date()
foreignCom1.name = 'Stage-2_Community-name'
foreignCom1.description = 'Stage-2_Community-description'
foreignCom1.creationDate = new Date()
await DbCommunity.insert(foreignCom1)
foreignCom2 = DbCommunity.create()
foreignCom2.foreign = true
foreignCom2.url = 'http://stage-3.gradido.net/api'
foreignCom2.publicKey = Buffer.from(ed25519KeyPairStaticHex[4].public, 'hex')
foreignCom2.privateKey = Buffer.from(ed25519KeyPairStaticHex[4].private, 'hex')
foreignCom2.communityUuid = 'Stage3-Com-UUID'
foreignCom2.authenticatedAt = new Date()
foreignCom2.name = 'Stage-3_Community-name'
foreignCom2.description = 'Stage-3_Community-description'
foreignCom2.creationDate = new Date()
await DbCommunity.insert(foreignCom2)
foreignCom1 = await createCommunity(true, false)
foreignCom2 = await createCommunity(true, false)
const com1FedCom = await createVerifiedFederatedCommunity('1_0', 100, foreignCom1, false)
const com1FedCom2 = await createVerifiedFederatedCommunity('1_1', 100, foreignCom1, false)
const com2FedCom = await createVerifiedFederatedCommunity('1_0', 10000, foreignCom2, false)
await Promise.all([
DbCommunity.insert(foreignCom1),
DbCommunity.insert(foreignCom2),
DbFederatedCommunity.insert(com1FedCom),
DbFederatedCommunity.insert(com1FedCom2),
DbFederatedCommunity.insert(com2FedCom)
])
})
it('returns 2 community entries', async () => {
await expect(query({ query: communitiesQuery })).resolves.toMatchObject({
data: {
communities: [
{
id: expect.any(Number),
foreign: homeCom1.foreign,
name: homeCom1.name,
description: homeCom1.description,
url: homeCom1.url,
creationDate: homeCom1.creationDate?.toISOString(),
uuid: homeCom1.communityUuid,
authenticatedAt: homeCom1.authenticatedAt?.toISOString(),
},
/*
{
id: expect.any(Number),
foreign: foreignCom1.foreign,
name: foreignCom1.name,
description: foreignCom1.description,
url: foreignCom1.url,
creationDate: foreignCom1.creationDate?.toISOString(),
uuid: foreignCom1.communityUuid,
authenticatedAt: foreignCom1.authenticatedAt?.toISOString(),
},
*/
{
id: expect.any(Number),
foreign: foreignCom2.foreign,
name: foreignCom2.name,
description: foreignCom2.description,
url: foreignCom2.url,
creationDate: foreignCom2.creationDate?.toISOString(),
uuid: foreignCom2.communityUuid,
authenticatedAt: foreignCom2.authenticatedAt?.toISOString(),
},
],
},
})
const result = await query({ query: reachableCommunities })
expect(result.data.reachableCommunities.length).toBe(2)
expect(result.data.reachableCommunities).toMatchObject([
{
foreign: homeCom1.foreign,
name: homeCom1.name,
description: homeCom1.description,
url: homeCom1.url,
uuid: homeCom1.communityUuid,
}, {
foreign: foreignCom1.foreign,
name: foreignCom1.name,
description: foreignCom1.description,
url: foreignCom1.url,
uuid: foreignCom1.communityUuid,
}
])
})
})
describe('search community by uuid', () => {
let homeCom: DbCommunity | null
beforeEach(async () => {
await cleanDB()
jest.clearAllMocks()
const admin = await userFactory(testEnv, peterLustig)
// login as admin
await mutate({ mutation: login, variables: peterLoginData })
beforeAll(async () => {
await DbCommunity.clear()
// HomeCommunity is still created in userFactory
homeCom = await getCommunityByUuid(admin.communityUuid)
homeCom = await createCommunity(false, false)
foreignCom1 = await createCommunity(true, false)
foreignCom2 = await createCommunity(true, false)
foreignCom1 = DbCommunity.create()
foreignCom1.foreign = true
foreignCom1.url = 'http://stage-2.gradido.net/api'
foreignCom1.publicKey = Buffer.from(
'8a1f9374b99c30d827b85dcd23f7e50328430d64ef65ef35bf375ea8eb9a2e1d',
'hex',
)
foreignCom1.privateKey = Buffer.from(
'f6c2a9d78e20a3c910f35b8ffcf824aa7b37f0d3d81bfc4c0e65e17a194b3a4a',
'hex',
)
// foreignCom1.communityUuid = 'Stage2-Com-UUID'
// foreignCom1.authenticatedAt = new Date()
foreignCom1.name = 'Stage-2_Community-name'
foreignCom1.description = 'Stage-2_Community-description'
foreignCom1.creationDate = new Date()
await DbCommunity.insert(foreignCom1)
foreignCom2 = DbCommunity.create()
foreignCom2.foreign = true
foreignCom2.url = 'http://stage-3.gradido.net/api'
foreignCom2.publicKey = Buffer.from(
'e047365a54082e8a7e9273da61b55c8134a2a0c836799ba12b78b9b0c52bc85f',
'hex',
)
foreignCom2.privateKey = Buffer.from(
'e047365a54082e8a7e9273da61b55c8134a2a0c836799ba12b78b9b0c52bc85f',
'hex',
)
foreignCom2.communityUuid = uuidv4()
foreignCom2.authenticatedAt = new Date()
foreignCom2.name = 'Stage-3_Community-name'
foreignCom2.description = 'Stage-3_Community-description'
foreignCom2.creationDate = new Date()
await DbCommunity.insert(foreignCom2)
await Promise.all([
DbCommunity.insert(homeCom),
DbCommunity.insert(foreignCom1),
DbCommunity.insert(foreignCom2),
])
})
it('finds the home-community by uuid', async () => {
@ -749,7 +549,6 @@ describe('CommunityResolver', () => {
url: homeCom?.url,
creationDate: homeCom?.creationDate?.toISOString(),
uuid: homeCom?.communityUuid,
authenticatedAt: homeCom?.authenticatedAt,
},
},
})
@ -769,76 +568,100 @@ describe('CommunityResolver', () => {
description: homeCom?.description,
url: homeCom?.url,
creationDate: homeCom?.creationDate?.toISOString(),
uuid: homeCom?.communityUuid,
authenticatedAt: homeCom?.authenticatedAt,
uuid: homeCom?.communityUuid
},
},
})
})
it('updates the home-community gmsApiKey', async () => {
await expect(
mutate({
mutation: updateHomeCommunityQuery,
variables: { uuid: homeCom?.communityUuid, gmsApiKey: 'gmsApiKey' },
}),
).resolves.toMatchObject({
data: {
updateHomeCommunity: {
id: expect.any(Number),
foreign: homeCom?.foreign,
name: homeCom?.name,
description: homeCom?.description,
url: homeCom?.url,
creationDate: homeCom?.creationDate?.toISOString(),
uuid: homeCom?.communityUuid,
authenticatedAt: homeCom?.authenticatedAt,
gmsApiKey: 'gmsApiKey',
},
},
})
})
it('throws error on updating a foreign-community', async () => {
expect(
await mutate({
mutation: updateHomeCommunityQuery,
variables: { uuid: foreignCom2.communityUuid, gmsApiKey: 'gmsApiKey' },
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('Error: Only the HomeCommunity could be modified!')],
}),
)
})
it('throws error on updating a community without uuid', async () => {
expect(
await mutate({
mutation: updateHomeCommunityQuery,
variables: { uuid: null, gmsApiKey: 'gmsApiKey' },
}),
).toEqual(
expect.objectContaining({
errors: [
new GraphQLError(`Variable "$uuid" of non-null type "String!" must not be null.`),
],
}),
)
})
it('throws error on updating a community with not existing uuid', async () => {
expect(
await mutate({
mutation: updateHomeCommunityQuery,
variables: { uuid: uuidv4(), gmsApiKey: 'gmsApiKey' },
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('HomeCommunity with uuid not found: ')],
}),
)
})
})
})
describe('update community', () => {
let homeCom: DbCommunity
let foreignCom1: DbCommunity
let foreignCom2: DbCommunity
beforeAll(async () => {
await DbCommunity.clear()
// create admin and login as admin
await userFactory(testEnv, peterLustig)
homeCom = (await getHomeCommunity())!
foreignCom1 = await createCommunity(true, false)
foreignCom2 = await createCommunity(true, false)
await Promise.all([
DbCommunity.insert(foreignCom1),
DbCommunity.insert(foreignCom2),
mutate({ mutation: login, variables: peterLoginData })
])
})
afterAll(async () => {
await cleanDB()
})
it('updates the home-community gmsApiKey', async () => {
await expect(
mutate({
mutation: updateHomeCommunityQuery,
variables: { uuid: homeCom?.communityUuid, gmsApiKey: 'gmsApiKey' },
}),
).resolves.toMatchObject({
data: {
updateHomeCommunity: {
foreign: homeCom?.foreign,
name: homeCom?.name,
description: homeCom?.description,
url: homeCom?.url,
creationDate: homeCom?.creationDate?.toISOString(),
uuid: homeCom?.communityUuid,
authenticatedAt: homeCom?.authenticatedAt,
gmsApiKey: 'gmsApiKey',
},
},
})
})
it('throws error on updating a foreign-community', async () => {
expect(
await mutate({
mutation: updateHomeCommunityQuery,
variables: { uuid: foreignCom2.communityUuid, gmsApiKey: 'gmsApiKey' },
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('Error: Only the HomeCommunity could be modified!')],
}),
)
})
it('throws error on updating a community without uuid', async () => {
expect(
await mutate({
mutation: updateHomeCommunityQuery,
variables: { uuid: null, gmsApiKey: 'gmsApiKey' },
}),
).toEqual(
expect.objectContaining({
errors: [
new GraphQLError(`Variable "$uuid" of non-null type "String!" must not be null.`),
],
}),
)
})
it('throws error on updating a community with not existing uuid', async () => {
expect(
await mutate({
mutation: updateHomeCommunityQuery,
variables: { uuid: uuidv4(), gmsApiKey: 'gmsApiKey' },
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('HomeCommunity with uuid not found: ')],
}),
)
})
})
})

View File

@ -1,12 +1,13 @@
import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity, getHomeCommunity } from 'database'
import {
Community as DbCommunity,
getReachableCommunities,
getHomeCommunity
} from 'database'
import { Arg, Args, Authorized, Mutation, Query, Resolver } from 'type-graphql'
import { IsNull, Not } from 'typeorm'
import { Paginated } from '@arg/Paginated'
import { EditCommunityInput } from '@input/EditCommunityInput'
import { AdminCommunityView } from '@model/AdminCommunityView'
import { Community } from '@model/Community'
import { FederatedCommunity } from '@model/FederatedCommunity'
import { RIGHTS } from '@/auth/RIGHTS'
import { LogError } from '@/server/LogError'
@ -17,25 +18,13 @@ import {
getCommunityByIdentifier,
getCommunityByUuid,
} from './util/communities'
import { updateAllDefinedAndChanged } from 'shared'
import { CONFIG } from '@/config'
@Resolver()
export class CommunityResolver {
@Authorized([RIGHTS.COMMUNITIES])
@Query(() => [FederatedCommunity])
async getCommunities(): Promise<FederatedCommunity[]> {
const dbFederatedCommunities: DbFederatedCommunity[] = await DbFederatedCommunity.find({
order: {
foreign: 'ASC',
createdAt: 'DESC',
lastAnnouncedAt: 'DESC',
},
})
return dbFederatedCommunities.map(
(dbCom: DbFederatedCommunity) => new FederatedCommunity(dbCom),
)
}
@Authorized([RIGHTS.COMMUNITIES])
@Authorized([RIGHTS.COMMUNITY_WITH_API_KEYS])
@Query(() => [AdminCommunityView])
async allCommunities(@Args() paginated: Paginated): Promise<AdminCommunityView[]> {
// communityUUID could be oneTimePassCode (uint32 number)
@ -44,17 +33,17 @@ export class CommunityResolver {
@Authorized([RIGHTS.COMMUNITIES])
@Query(() => [Community])
async communities(): Promise<Community[]> {
const dbCommunities: DbCommunity[] = await DbCommunity.find({
where: { communityUuid: Not(IsNull()) }, //, authenticatedAt: Not(IsNull()) },
order: {
name: 'ASC',
},
async reachableCommunities(): Promise<Community[]> {
const dbCommunities: DbCommunity[] = await getReachableCommunities(
CONFIG.FEDERATION_VALIDATE_COMMUNITY_TIMER * 2, {
// order by
foreign: 'ASC', // home community first
name: 'ASC', // sort foreign communities by name
})
return dbCommunities.map((dbCom: DbCommunity) => new Community(dbCom))
}
@Authorized([RIGHTS.COMMUNITY_BY_IDENTIFIER])
@Authorized([RIGHTS.COMMUNITIES])
@Query(() => Community)
async communityByIdentifier(
@Arg('communityIdentifier') communityIdentifier: string,
@ -67,7 +56,7 @@ export class CommunityResolver {
return new Community(community)
}
@Authorized([RIGHTS.HOME_COMMUNITY])
@Authorized([RIGHTS.COMMUNITIES])
@Query(() => Community)
async homeCommunity(): Promise<Community> {
const community = await getHomeCommunity()
@ -78,10 +67,10 @@ export class CommunityResolver {
}
@Authorized([RIGHTS.COMMUNITY_UPDATE])
@Mutation(() => Community)
@Mutation(() => AdminCommunityView)
async updateHomeCommunity(
@Args() { uuid, gmsApiKey, location, hieroTopicId }: EditCommunityInput,
): Promise<Community> {
): Promise<AdminCommunityView> {
const homeCom = await getCommunityByUuid(uuid)
if (!homeCom) {
throw new LogError('HomeCommunity with uuid not found: ', uuid)
@ -89,18 +78,22 @@ export class CommunityResolver {
if (homeCom.foreign) {
throw new LogError('Error: Only the HomeCommunity could be modified!')
}
if (
homeCom.gmsApiKey !== gmsApiKey ||
homeCom.location !== location ||
homeCom.hieroTopicId !== hieroTopicId
) {
homeCom.gmsApiKey = gmsApiKey ?? null
if (location) {
homeCom.location = Location2Point(location)
let updated = false
// if location is undefined, it should not be changed
// if location is null, it should be set to null
if (typeof location !== 'undefined') {
const newLocation = location ? Location2Point(location) : null
if (newLocation !== homeCom.location) {
homeCom.location = newLocation
updated = true
}
homeCom.hieroTopicId = hieroTopicId ?? null
}
if (updateAllDefinedAndChanged(homeCom, { gmsApiKey, hieroTopicId })) {
updated = true
}
if (updated) {
await DbCommunity.save(homeCom)
}
return new Community(homeCom)
return new AdminCommunityView(homeCom)
}
}

View File

@ -14,7 +14,7 @@ import { User } from '@model/User'
import { QueryLinkResult } from '@union/QueryLinkResult'
import { Decay, interpretEncryptedTransferArgs, TransactionTypeId } from 'core'
import {
AppDatabase, Community as DbCommunity, Contribution as DbContribution,
AppDatabase, Contribution as DbContribution,
ContributionLink as DbContributionLink, FederatedCommunity as DbFederatedCommunity, Transaction as DbTransaction,
TransactionLink as DbTransactionLink,
User as DbUser,
@ -36,7 +36,7 @@ import { Context, getClientTimezoneOffset, getUser } from '@/server/context'
import { calculateBalance } from '@/util/validate'
import { fullName } from 'core'
import { TRANSACTION_LINK_LOCK, TRANSACTIONS_LOCK } from 'database'
import { calculateDecay, decode, DisburseJwtPayloadType, encode, encryptAndSign, EncryptedJWEJwtPayloadType, RedeemJwtPayloadType, verify } from 'shared'
import { calculateDecay, compoundInterest, decayFormula, decode, DisburseJwtPayloadType, encode, encryptAndSign, EncryptedJWEJwtPayloadType, RedeemJwtPayloadType, verify } from 'shared'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { DisbursementClient as V1_0_DisbursementClient } from '@/federation/client/1_0/DisbursementClient'
@ -48,7 +48,6 @@ import { randombytes_random } from 'sodium-native'
import { executeTransaction } from './TransactionResolver'
import {
getAuthenticatedCommunities,
getCommunityByIdentifier,
getCommunityByPublicKey,
getCommunityByUuid,
} from './util/communities'
@ -90,7 +89,7 @@ export class TransactionLinkResolver {
const createdDate = new Date()
const validUntil = transactionLinkExpireDate(createdDate)
const holdAvailableAmount = amount.minus(calculateDecay(amount, createdDate, validUntil).decay)
const holdAvailableAmount = compoundInterest(amount, CODE_VALID_DAYS_DURATION * 24 * 60 * 60)
// validate amount
const sendBalance = await calculateBalance(user.id, holdAvailableAmount.mul(-1), createdDate)

View File

@ -439,9 +439,7 @@ export class TransactionResolver {
logger.debug(
`sendCoins(recipientCommunityIdentifier=${recipientCommunityIdentifier}, recipientIdentifier=${recipientIdentifier}, amount=${amount}, memo=${memo})`,
)
const homeCom = await DbCommunity.findOneOrFail({ where: { foreign: false } })
const senderUser = getUser(context)
if (!recipientCommunityIdentifier || (await isHomeCommunity(recipientCommunityIdentifier))) {
// processing sendCoins within sender and recipient are both in home community
const recipientUser = await findUserByIdentifier(

View File

@ -25,7 +25,7 @@ import {
Root,
} from 'type-graphql'
import { IRestResponse } from 'typed-rest-client'
import { EntityNotFoundError, In, Point } from 'typeorm'
import { EntityManager, EntityNotFoundError, In, Point } from 'typeorm'
import { v4 as uuidv4 } from 'uuid'
import { UserArgs } from '@arg//UserArgs'
@ -104,6 +104,7 @@ import { deleteUserRole, setUserRole } from './util/modifyUserRole'
import { sendUserToGms } from './util/sendUserToGms'
import { syncHumhub } from './util/syncHumhub'
import { validateAlias } from 'core'
import { updateAllDefinedAndChanged } from 'shared'
const LANGUAGES = ['de', 'en', 'es', 'fr', 'nl']
const DEFAULT_LANGUAGE = 'de'
@ -727,18 +728,22 @@ export class UserResolver {
user.humhubPublishName as PublishNameType,
)
// try {
if (firstName) {
user.firstName = firstName
}
if (lastName) {
user.lastName = lastName
}
let updated = updateAllDefinedAndChanged(user, {
firstName,
lastName,
hideAmountGDD,
hideAmountGDT,
humhubAllowed,
gmsAllowed,
gmsPublishName: gmsPublishName?.valueOf(),
humhubPublishName: humhubPublishName?.valueOf(),
gmsPublishLocation: gmsPublishLocation?.valueOf(),
})
// currently alias can only be set, not updated
if (alias && !user.alias && (await validateAlias(alias))) {
user.alias = alias
updated = true
}
if (language) {
@ -748,6 +753,7 @@ export class UserResolver {
}
user.language = language
i18n.setLocale(language)
updated = true
}
if (password && passwordNew) {
@ -768,55 +774,28 @@ export class UserResolver {
// Save new password hash and newly encrypted private key
user.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
user.password = await encryptPassword(user, passwordNew)
updated = true
}
// Save hideAmountGDD value
if (hideAmountGDD !== undefined) {
user.hideAmountGDD = hideAmountGDD
}
// Save hideAmountGDT value
if (hideAmountGDT !== undefined) {
user.hideAmountGDT = hideAmountGDT
}
if (humhubAllowed !== undefined) {
user.humhubAllowed = humhubAllowed
}
if (gmsAllowed !== undefined) {
user.gmsAllowed = gmsAllowed
}
if (gmsPublishName !== null && gmsPublishName !== undefined) {
user.gmsPublishName = gmsPublishName
}
if (humhubPublishName !== null && humhubPublishName !== undefined) {
user.humhubPublishName = humhubPublishName
}
if (gmsLocation) {
user.location = Location2Point(gmsLocation)
updated = true
}
if (gmsPublishLocation !== null && gmsPublishLocation !== undefined) {
user.gmsPublishLocation = gmsPublishLocation
// early exit if no update was made
if (!updated) {
return true
}
// } catch (err) {
// console.log('error:', err)
// }
const queryRunner = db.getDataSource().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('REPEATABLE READ')
try {
await queryRunner.manager.save(user).catch((error) => {
throw new LogError('Error saving user', error)
})
await queryRunner.commitTransaction()
logger.debug('writing User data successful...', new UserLoggingView(user))
} catch (e) {
await queryRunner.rollbackTransaction()
throw new LogError('Error on writing updated user data', e)
} finally {
await queryRunner.release()
await DbUser.save(user)
} catch (error) {
const errorMessage = 'Error saving user'
logger.error(errorMessage, error)
throw new Error(errorMessage)
}
logger.info('updateUserInfos() successfully finished...')
logger.debug('writing User data successful...', new UserLoggingView(user))
await EVENT_USER_INFO_UPDATE(user)
// validate if user settings are changed with relevance to update gms-user
@ -1151,6 +1130,12 @@ export class UserResolver {
@Args()
{ identifier, communityIdentifier }: UserArgs,
): Promise<User> {
// check if identifier contain community and user identifier
if (identifier.includes('/')) {
const parts = identifier.split('/')
communityIdentifier = parts[0]
identifier = parts[1]
}
const foundDbUser = await findUserByIdentifier(identifier, communityIdentifier)
if (!foundDbUser) {
createLogger().debug('User not found', identifier, communityIdentifier)

View File

@ -375,7 +375,6 @@ export const logout = gql`
export const updateHomeCommunityQuery = gql`
mutation ($uuid: String!, $gmsApiKey: String!) {
updateHomeCommunity(uuid: $uuid, gmsApiKey: $gmsApiKey) {
id
foreign
name
description

View File

@ -135,18 +135,14 @@ export const listGDTEntriesQuery = gql`
}
`
export const communitiesQuery = gql`
query {
communities {
id
export const reachableCommunities = gql`
query {
reachableCommunities {
foreign
uuid
name
description
url
creationDate
uuid
authenticatedAt
gmsApiKey
}
}
`
@ -162,7 +158,6 @@ export const getCommunityByIdentifierQuery = gql`
creationDate
uuid
authenticatedAt
gmsApiKey
}
}
`
@ -178,24 +173,6 @@ export const getHomeCommunityQuery = gql`
creationDate
uuid
authenticatedAt
gmsApiKey
}
}
`
export const getCommunities = gql`
query {
getCommunities {
id
foreign
publicKey
endPoint
apiVersion
lastAnnouncedAt
verifiedAt
lastErrorAt
createdAt
updatedAt
}
}
`
@ -268,7 +245,7 @@ export const listContributions = gql`
}
`
export const listAllContributions = `
export const listAllContributions = gql`
query ($pagination: Paginated!) {
listAllContributions(pagination: $pagination) {
contributionCount

View File

@ -0,0 +1,10 @@
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
'ALTER TABLE `openai_threads` ADD COLUMN `updatedAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP AFTER `createdAt`;'
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE `openai_threads` DROP COLUMN `updatedAt`;')
}

View File

@ -1,13 +1,16 @@
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm'
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm'
@Entity('openai_threads')
export class OpenaiThreads extends BaseEntity {
@PrimaryColumn({ type: 'char', length: 30 })
id: string
@CreateDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
@CreateDateColumn({ type: 'timestamp' })
createdAt: Date
@UpdateDateColumn({ type: 'timestamp' })
updatedAt: Date
@Column({ name: 'user_id', type: 'int', unsigned: true })
userId: number
}

View File

@ -1,8 +1,8 @@
import { Community as DbCommunity } from '..'
import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity } from '..'
import { AppDatabase } from '../AppDatabase'
import { getHomeCommunity } from './communities'
import { describe, expect, it, beforeAll, afterAll } from 'vitest'
import { createCommunity } from '../seeds/homeCommunity'
import { getHomeCommunity, getReachableCommunities } from './communities'
import { describe, expect, it, beforeEach, beforeAll, afterAll } from 'vitest'
import { createCommunity, createVerifiedFederatedCommunity } from '../seeds/community'
const db = AppDatabase.getInstance()
@ -14,8 +14,10 @@ afterAll(async () => {
})
describe('community.queries', () => {
beforeAll(async () => {
// clean db for every test case
beforeEach(async () => {
await DbCommunity.clear()
await DbFederatedCommunity.clear()
})
describe('getHomeCommunity', () => {
it('should return null if no home community exists', async () => {
@ -37,4 +39,51 @@ describe('community.queries', () => {
expect(community?.privateKey).toStrictEqual(homeCom.privateKey)
})
})
describe('getReachableCommunities', () => {
it('home community counts also to reachable communities', async () => {
await createCommunity(false)
expect(await getReachableCommunities(1000)).toHaveLength(1)
})
it('foreign communities authenticated within chosen range', async () => {
const com1 = await createCommunity(true)
const com2 = await createCommunity(true)
const com3 = await createCommunity(true)
await createVerifiedFederatedCommunity('1_0', 100, com1)
await createVerifiedFederatedCommunity('1_0', 500, com2)
// outside of range
await createVerifiedFederatedCommunity('1_0', 1200, com3)
const communities = await getReachableCommunities(1000)
expect(communities).toHaveLength(2)
expect(communities[0].communityUuid).toBe(com1.communityUuid)
expect(communities[1].communityUuid).toBe(com2.communityUuid)
})
it('multiple federated community api version, result in one community', async () => {
const com1 = await createCommunity(true)
await createVerifiedFederatedCommunity('1_0', 100, com1)
await createVerifiedFederatedCommunity('1_1', 100, com1)
expect(await getReachableCommunities(1000)).toHaveLength(1)
})
it('multiple federated community api version one outside of range, result in one community', async () => {
const com1 = await createCommunity(true)
await createVerifiedFederatedCommunity('1_0', 100, com1)
// outside of range
await createVerifiedFederatedCommunity('1_1', 1200, com1)
expect(await getReachableCommunities(1000)).toHaveLength(1)
})
it('foreign and home community', async () => {
// home community
await createCommunity(false)
const com1 = await createCommunity(true)
const com2 = await createCommunity(true)
await createVerifiedFederatedCommunity('1_0', 400, com1)
await createVerifiedFederatedCommunity('1_0', 1200, com2)
expect(await getReachableCommunities(1000)).toHaveLength(2)
})
it('not verified inside time frame federated community', async () => {
const com1 = await createCommunity(true)
await createVerifiedFederatedCommunity('1_0', 1200, com1)
expect(await getReachableCommunities(1000)).toHaveLength(0)
})
})
})

View File

@ -1,10 +1,14 @@
import { FindOptionsOrder, FindOptionsWhere, IsNull, MoreThanOrEqual, Not } from 'typeorm'
import { Community as DbCommunity } from '../entity'
import { urlSchema, uuidv4Schema } from 'shared'
/**
* Retrieves the home community, i.e., a community that is not foreign.
* @returns A promise that resolves to the home community, or null if no home community was found
*/
export async function getHomeCommunity(): Promise<DbCommunity | null> {
// TODO: Put in Cache, it is needed nearly always
// TODO: return only DbCommunity or throw to reduce unnecessary checks, because there should be always a home community
return await DbCommunity.findOne({
where: { foreign: false },
})
@ -15,3 +19,45 @@ export async function getCommunityByUuid(communityUuid: string): Promise<DbCommu
where: [{ communityUuid }],
})
}
export function findWithCommunityIdentifier(communityIdentifier: string): FindOptionsWhere<DbCommunity> {
const where: FindOptionsWhere<DbCommunity> = {}
// pre filter identifier type to reduce db query complexity
if (urlSchema.safeParse(communityIdentifier).success) {
where.url = communityIdentifier
} else if (uuidv4Schema.safeParse(communityIdentifier).success) {
where.communityUuid = communityIdentifier
} else {
where.name = communityIdentifier
}
return where
}
export async function getCommunityWithFederatedCommunityByIdentifier(
communityIdentifier: string,
): Promise<DbCommunity | null> {
return await DbCommunity.findOne({
where: { ...findWithCommunityIdentifier(communityIdentifier) },
relations: ['federatedCommunities'],
})
}
// returns all reachable communities
// home community and all federated communities which have been verified within the last authenticationTimeoutMs
export async function getReachableCommunities(
authenticationTimeoutMs: number,
order?: FindOptionsOrder<DbCommunity>
): Promise<DbCommunity[]> {
return await DbCommunity.find({
where: [
{
authenticatedAt: Not(IsNull()),
federatedCommunities: {
verifiedAt: MoreThanOrEqual(new Date(Date.now() - authenticationTimeoutMs))
}
},
{ foreign: false },
],
order,
})
}

View File

@ -14,7 +14,7 @@ import { peterLustig } from '../seeds/users/peter-lustig'
import { bobBaumeister } from '../seeds/users/bob-baumeister'
import { garrickOllivander } from '../seeds/users/garrick-ollivander'
import { describe, expect, it, beforeAll, afterAll } from 'vitest'
import { createCommunity } from '../seeds/homeCommunity'
import { createCommunity } from '../seeds/community'
import { v4 as uuidv4 } from 'uuid'
import Decimal from 'decimal.js-light'

View File

@ -4,7 +4,7 @@ import { aliasExists, findUserByIdentifier } from './user'
import { userFactory } from '../seeds/factory/user'
import { bibiBloxberg } from '../seeds/users/bibi-bloxberg'
import { describe, expect, it, beforeAll, afterAll, beforeEach, } from 'vitest'
import { createCommunity } from '../seeds/homeCommunity'
import { createCommunity } from '../seeds/community'
import { peterLustig } from '../seeds/users/peter-lustig'
import { bobBaumeister } from '../seeds/users/bob-baumeister'
import { getLogger, printLogs, clearLogs } from '../../../config-schema/test/testSetup.vitest'

View File

@ -1,9 +1,8 @@
import { Raw } from 'typeorm'
import { Community, User as DbUser, UserContact as DbUserContact } from '../entity'
import { FindOptionsWhere } from 'typeorm'
import { aliasSchema, emailSchema, uuidv4Schema, urlSchema } from 'shared'
import { User as DbUser, UserContact as DbUserContact } from '../entity'
import { aliasSchema, emailSchema, uuidv4Schema } from 'shared'
import { getLogger } from 'log4js'
import { LOG4JS_QUERIES_CATEGORY_NAME } from './index'
import { findWithCommunityIdentifier, LOG4JS_QUERIES_CATEGORY_NAME } from './index'
export async function aliasExists(alias: string): Promise<boolean> {
const user = await DbUser.findOne({
@ -22,11 +21,9 @@ export const findUserByIdentifier = async (
identifier: string,
communityIdentifier?: string,
): Promise<DbUser | null> => {
const communityWhere: FindOptionsWhere<Community> = urlSchema.safeParse(communityIdentifier).success
? { url: communityIdentifier }
: uuidv4Schema.safeParse(communityIdentifier).success
? { communityUuid: communityIdentifier }
: { name: communityIdentifier }
const communityWhere = communityIdentifier
? findWithCommunityIdentifier(communityIdentifier)
: undefined
if (uuidv4Schema.safeParse(identifier).success) {
return DbUser.findOne({

View File

@ -0,0 +1,42 @@
import { Community, FederatedCommunity } from '../entity'
import { randomBytes } from 'node:crypto'
import { v4 as uuidv4 } from 'uuid'
export async function createCommunity(foreign: boolean, save: boolean = true): Promise<Community> {
const community = new Community()
community.publicKey = randomBytes(32)
community.communityUuid = uuidv4()
community.name = 'HomeCommunity-name'
community.creationDate = new Date()
if(foreign) {
community.foreign = true
community.name = 'ForeignCommunity-name'
community.description = 'ForeignCommunity-description'
community.url = `http://foreign-${Math.random()}/api`
community.authenticatedAt = new Date()
} else {
community.foreign = false
// todo: generate valid public/private key pair (ed25519)
community.privateKey = randomBytes(64)
community.name = 'HomeCommunity-name'
community.description = 'HomeCommunity-description'
community.url = 'http://localhost/api'
}
return save ? await community.save() : community
}
export async function createVerifiedFederatedCommunity(
apiVersion: string,
verifiedBeforeMs: number,
community: Community,
save: boolean = true
): Promise<FederatedCommunity> {
const federatedCommunity = new FederatedCommunity()
federatedCommunity.apiVersion = apiVersion
federatedCommunity.endPoint = community.url
federatedCommunity.publicKey = community.publicKey
federatedCommunity.community = community
federatedCommunity.verifiedAt = new Date(Date.now() - verifiedBeforeMs)
return save ? await federatedCommunity.save() : federatedCommunity
}

View File

@ -1,26 +0,0 @@
import { Community } from '../entity'
import { randomBytes } from 'node:crypto'
import { v4 as uuidv4 } from 'uuid'
export async function createCommunity(foreign: boolean): Promise<Community> {
const homeCom = new Community()
homeCom.publicKey = randomBytes(32)
homeCom.communityUuid = uuidv4()
homeCom.authenticatedAt = new Date()
homeCom.name = 'HomeCommunity-name'
homeCom.creationDate = new Date()
if(foreign) {
homeCom.foreign = true
homeCom.name = 'ForeignCommunity-name'
homeCom.description = 'ForeignCommunity-description'
homeCom.url = 'http://foreign/api'
} else {
homeCom.foreign = false
homeCom.privateKey = randomBytes(64)
homeCom.name = 'HomeCommunity-name'
homeCom.description = 'HomeCommunity-description'
homeCom.url = 'http://localhost/api'
}
return await homeCom.save()
}

View File

@ -22,10 +22,10 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { useRoute } from 'vue-router'
import { selectCommunities } from '@/graphql/queries'
import { reachableCommunities } from '@/graphql/communities.graphql'
import { useAppToast } from '@/composables/useToast'
const props = defineProps({
@ -33,9 +33,13 @@ const props = defineProps({
type: Object,
default: () => ({}),
},
communityIdentifier: {
type: String,
default: '',
},
})
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['update:modelValue', 'communitiesLoaded'])
const route = useRoute()
const { toastError } = useAppToast()
@ -43,20 +47,31 @@ const { toastError } = useAppToast()
const communities = ref([])
const validCommunityIdentifier = ref(false)
const { onResult } = useQuery(selectCommunities)
const { onResult } = useQuery(reachableCommunities)
onResult(({ data }) => {
// console.log('CommunitySwitch.onResult...data=', data)
if (data) {
communities.value = data.communities
communities.value = data.reachableCommunities
setDefaultCommunity()
if (data.communities.length === 1) {
if (data.reachableCommunities.length === 1) {
validCommunityIdentifier.value = true
}
emit('communitiesLoaded', data.reachableCommunities)
}
})
const communityIdentifier = computed(() => route.params.communityIdentifier)
const communityIdentifier = computed(
() => route.params.communityIdentifier || props.communityIdentifier,
)
watch(
() => communityIdentifier.value,
() => {
// console.log('CommunitySwitch.communityIdentifier.value', value)
setDefaultCommunity()
},
)
function updateCommunity(community) {
// console.log('CommunitySwitch.updateCommunity...community=', community)

View File

@ -165,18 +165,35 @@ const validationSchema = computed(() => {
.required('form.validation.contributionMemo.required'),
hours: string()
.typeError({ key: 'form.validation.hours.typeError', values: { min: 0.01, max: maxHours } })
.required()
.transform((currentValue) =>
!currentValue || typeof currentValue !== 'string'
? currentValue
: currentValue.replace(',', '.'),
)
// min and max are needed for html min max which validatedInput will take from this scheme
.min(0.01, ({ min }) => ({ key: 'form.validation.hours.min', values: { min } }))
.max(maxHours, ({ max }) => ({ key: 'form.validation.hours.max', values: { max } }))
.test('decimal-places', 'form.validation.hours.decimal-places', (value) => {
if (value === undefined || value === null) return true
return /^\d+(\.\d{0,2})?$/.test(value.toString())
}),
})
// min and max are not working with string, so we need to do it manually
.test('min-hours', { key: 'form.validation.hours.min', values: { min: 0.01 } }, (value) => {
if (value === undefined || value === null || Number.isNaN(parseFloat(value))) {
return false
}
return parseFloat(value) >= 0.01
})
.test(
'max-hours',
{ key: 'form.validation.hours.max', values: { max: maxHours } },
(value) => {
if (value === undefined || value === null || Number.isNaN(parseFloat(value))) {
return false
}
return parseFloat(value) <= maxHours
},
),
amount: number().min(0.01).max(maxAmounts),
})
})

View File

@ -20,20 +20,29 @@
<BCol>{{ $t('advanced-calculation') }}</BCol>
</BRow>
<BRow class="pe-3" offset="2">
<BCol offset="2">{{ $t('form.current_balance') }}</BCol>
<BCol offset="2">{{ $t('form.current_available') }}</BCol>
<BCol>{{ $filters.GDD(balance) }}</BCol>
</BRow>
<BRow class="pe-3">
<BCol offset="2">
<strong>{{ $t('form.your_amount') }}</strong>
<strong>{{ $t('form.link_amount') }}</strong>
</BCol>
<BCol class="borderbottom">
<BCol>
<strong>{{ $filters.GDD(amount * -1) }}</strong>
</BCol>
</BRow>
<BRow class="pe-3">
<BCol offset="2">{{ $t('form.new_balance') }}</BCol>
<BCol>{{ $filters.GDD(balance - amount) }}</BCol>
<BCol offset="2">{{ $t('decay.decay') }}</BCol>
<BCol class="borderbottom">{{ $filters.GDD(amount - blockedAmount) }}</BCol>
</BRow>
<BRow class="pe-3">
<BCol offset="2">{{ $t('form.available_after') }}</BCol>
<BCol>{{ $filters.GDD(balance - blockedAmount) }}</BCol>
</BRow>
<BRow class="pe-6 mt-2">
<BCol offset="1">
<small>{{ $t('form.link_decay_description') }}</small>
</BCol>
</BRow>
<BRow class="mt-5">
<BCol cols="12" md="6" lg="6">
@ -57,6 +66,7 @@
</div>
</template>
<script>
import { LINK_COMPOUND_INTEREST_FACTOR } from '@/constants'
export default {
name: 'TransactionConfirmationLink',
props: {
@ -68,7 +78,13 @@ export default {
},
computed: {
totalBalance() {
return this.balance - this.amount * 1.028
return this.balance - this.blockedAmount
},
blockedAmount() {
// correct formula
return this.amount * LINK_COMPOUND_INTEREST_FACTOR
// same formula as in backend
// return 2 * this.amount - this.amount * Math.pow(0.99999997803504048, 1209600)
},
disabled() {
if (this.totalBalance < 0) {

View File

@ -26,6 +26,7 @@ vi.mock('vue-router', () => ({
}))
const mockUseQuery = vi.fn()
const mockUseLazyQuery = vi.fn()
vi.mock('@vue/apollo-composable', () => ({
useQuery: (...args) => {
mockUseQuery(...args)
@ -35,6 +36,12 @@ vi.mock('@vue/apollo-composable', () => ({
error: ref(null),
}
},
useLazyQuery: (...args) => {
mockUseLazyQuery(...args)
return {
refetch: vi.fn(() => true),
}
},
}))
vi.mock('@/composables/useToast', () => ({

View File

@ -48,7 +48,9 @@
<community-switch
:disabled="isBalanceEmpty"
:model-value="form.targetCommunity"
:community-identifier="autoCommunityIdentifier"
@update:model-value="updateField($event, 'targetCommunity')"
@communities-loaded="setCommunities"
/>
</BCol>
</BRow>
@ -62,7 +64,7 @@
:label="$t('form.recipient')"
:placeholder="$t('form.identifier')"
:rules="validationSchema.fields.identifier"
:disabled="isBalanceEmpty"
:disabled="isBalanceEmpty || isCommunitiesEmpty"
:disable-smart-valid-state="disableSmartValidState"
@update:model-value="updateField"
/>
@ -171,6 +173,8 @@ const props = defineProps({
const entityDataToForm = computed(() => ({ ...props }))
const form = reactive({ ...entityDataToForm.value })
const disableSmartValidState = ref(false)
const communities = ref([])
const autoCommunityIdentifier = ref('')
const emit = defineEmits(['set-transaction'])
@ -191,6 +195,10 @@ const userIdentifier = computed(() => {
return null
})
function setCommunities(returnedCommunities) {
communities.value = returnedCommunities
}
const validationSchema = computed(() => {
const amountSchema = number()
.required()
@ -214,7 +222,24 @@ const validationSchema = computed(() => {
return object({
memo: memoSchema,
amount: amountSchema,
identifier: identifierSchema,
identifier: identifierSchema.test(
'community-is-reachable',
'form.validation.identifier.communityIsReachable',
(value) => {
const parts = value.split('/')
// early exit if no community id is in identifier string
if (parts.length !== 2) {
return true
}
return communities.value.some((community) => {
return (
community.uuid === parts[0] ||
community.name === parts[0] ||
community.url === parts[0]
)
})
},
),
})
} else {
// don't need identifier schema if it is a transaction link or identifier was set via url
@ -224,7 +249,6 @@ const validationSchema = computed(() => {
})
}
})
const formIsInvalid = computed(() => !validationSchema.value.isValidSync(form))
const updateField = (newValue, name) => {
@ -234,6 +258,7 @@ const updateField = (newValue, name) => {
}
const isBalanceEmpty = computed(() => props.balance <= 0)
const isCommunitiesEmpty = computed(() => communities.value.length === 0)
const { result: userResult, error: userError } = useQuery(
user,
@ -258,8 +283,35 @@ watch(userError, (error) => {
}
})
// if identifier contain valid community identifier of a reachable community:
// set it as target community and change community-switch to show only current value, instead of select
watch(
() => form.identifier,
(value) => {
autoCommunityIdentifier.value = ''
const parts = value.split('/')
if (parts.length === 2) {
const com = communities.value.find(
(community) =>
community.uuid === parts[0] || community.name === parts[0] || community.url === parts[0],
)
if (com) {
form.targetCommunity = com
autoCommunityIdentifier.value = com.uuid
}
}
},
)
function onSubmit() {
const transformedForm = validationSchema.value.cast(form)
const parts = transformedForm.identifier.split('/')
if (parts.length === 2) {
transformedForm.identifier = parts[1]
transformedForm.targetCommunity = communities.value.find((com) => {
return com.uuid === parts[0] || com.name === parts[0] || com.url === parts[0]
})
}
emit('set-transaction', {
...transformedForm,
selected: radioSelected.value,

View File

@ -1,2 +1,5 @@
export const GDD_PER_HOUR = 20
export const PAGE_SIZE = 25
// compound interest factor (decay reversed) for 14 days (hard coded backend link timeout)
// 365.2425 days per year (gregorian calendar year)
export const LINK_COMPOUND_INTEREST_FACTOR = Math.pow(2, 14 / 365.2425)

View File

@ -0,0 +1,9 @@
query reachableCommunities {
reachableCommunities {
foreign
uuid
name
description
url
}
}

View File

@ -91,19 +91,6 @@ export const listGDTEntriesQuery = gql`
}
}
`
export const selectCommunities = gql`
query {
communities {
uuid
name
description
foreign
url
}
}
`
export const queryOptIn = gql`
query ($optIn: String!) {
queryOptIn(optIn: $optIn)

View File

@ -3,4 +3,10 @@ fragment userFields on User {
firstName
lastName
alias
}
}
query user($identifier: String!, $communityIdentifier: String) {
user(identifier: $identifier, communityIdentifier: $communityIdentifier) {
...userFields
}
}

View File

@ -164,10 +164,12 @@
"form": {
"amount": "Betrag",
"at": "am",
"available_after": "Verfügbar nach Bestätigung",
"cancel": "Abbrechen",
"change": "Ändern",
"check_now": "Jetzt prüfen",
"close": "Schließen",
"current_available": "Aktuell verfügbar",
"current_balance": "Aktueller Kontostand",
"date": "Datum",
"description": "Beschreibung",
@ -178,6 +180,8 @@
"hours": "Stunden",
"identifier": "Email, Nutzername oder Gradido ID",
"lastname": "Nachname",
"link_amount": "Link-Betrag",
"link_decay_description": "Der Link-Betrag wird zusammen mit der maximalen Vergänglichkeit blockiert. Nach Einlösen, Verfallen oder Löschen des Links wird der Rest wieder freigegeben.",
"memo": "Nachricht",
"message": "Nachricht",
"new_balance": "Neuer Kontostand nach Bestätigung",
@ -231,6 +235,7 @@
},
"gddSendAmount": "Das Feld {_field_} muss eine Zahl zwischen {min} und {max} mit höchstens zwei Nachkommastellen sein.",
"identifier": {
"communityIsReachable": "Community nicht gefunden oder nicht erreichbar!",
"required": "Der Empfänger ist ein Pflichtfeld.",
"typeError": "Der Empfänger muss eine Email, ein Nutzernamen oder eine Gradido ID sein."
},

View File

@ -164,10 +164,12 @@
"form": {
"amount": "Amount",
"at": "at",
"available_after": "Available after confirmation",
"cancel": "Cancel",
"change": "Change",
"check_now": "Check now",
"close": "Close",
"current_available": "Currently available",
"current_balance": "Current Balance",
"date": "Date",
"description": "Description",
@ -178,6 +180,8 @@
"hours": "Hours",
"identifier": "Email, username or gradido ID",
"lastname": "Lastname",
"link_amount": "Link amount",
"link_decay_description": "The link amount is blocked along with the maximum decay. After the link has been redeemed, expired, or deleted, the rest is released again.",
"memo": "Message",
"message": "Message",
"new_balance": "Account balance after confirmation",
@ -231,6 +235,7 @@
},
"gddSendAmount": "The {_field_} field must be a number between {min} and {max} with at most two digits after the decimal point.",
"identifier": {
"communityIsReachable": "Community not found our not reachable!",
"required": "The recipient is a required field.",
"typeError": "The recipient must be an email, a username or a Gradido ID."
},

View File

@ -146,10 +146,12 @@
"form": {
"amount": "Importe",
"at": "am",
"available_after": "Disponible tras la confirmación",
"cancel": "Cancelar",
"change": "Cambiar",
"check_now": "Revisar",
"close": "Cerrar",
"current_available": "Actualmente disponible",
"current_balance": "Saldo de cuenta actual",
"date": "Fecha",
"description": "Descripción",
@ -158,6 +160,8 @@
"from": "De",
"generate_now": "crear ahora",
"lastname": "Apellido",
"link_amount": "Importe del enlace",
"link_decay_description": "El importe del enlace se bloquea junto con la ransience máxima. Una vez que el enlace se ha canjeado, caducado o eliminado, el resto se libera de nuevo.",
"memo": "Mensaje",
"message": "Noticia",
"new_balance": "Saldo de cuenta nuevo depués de confirmación",
@ -185,6 +189,11 @@
"to1": "para",
"validation": {
"gddSendAmount": "El campo {_field_} debe ser un número entre {min} y {max} con un máximo de dos decimales",
"identifier": {
"communityIsReachable": "Comunidad no encontrada o no alcanzable!",
"required": "El destinatario es un campo obligatorio.",
"typeError": "El destinatario debe ser un email, un nombre de usuario o un Gradido ID."
},
"is-not": "No es posible transferirte Gradidos a ti mismo",
"requiredField": "El campo {fieldName} es obligatorio",
"usernmae-regex": "El nombre de usuario debe comenzar con una letra seguida de al menos dos caracteres alfanuméricos.",

View File

@ -151,10 +151,12 @@
"form": {
"amount": "Montant",
"at": "à",
"available_after": "Disponible après confirmation",
"cancel": "Annuler",
"change": "Changer",
"check_now": "Vérifier maintenant",
"close": "Fermer",
"current_available": "Actuellement disponible",
"current_balance": "Solde actuel",
"date": "Date",
"description": "Description",
@ -164,6 +166,8 @@
"generate_now": "Produire maintenant",
"hours": "Heures",
"lastname": "Nom",
"link_amount": "Montant du lien",
"link_decay_description": "Le montant du lien est bloqué avec le dépérissement maximale. Une fois le lien utilisé, expiré ou supprimé, le reste est à nouveau débloqué.",
"memo": "Note",
"message": "Message",
"new_balance": "Montant du solde après confirmation",
@ -191,6 +195,11 @@
"validation": {
"gddCreationTime": "Le champ {_field_} doit comprendre un nombre entre {min} et {max} avec un maximum de une décimale.",
"gddSendAmount": "Le champ {_field_} doit comprendre un nombre entre {min} et {max} avec un maximum de deux chiffres après la virgule",
"identifier": {
"communityIsReachable": "Communauté non joignable!",
"required": "Le destinataire est un champ obligatoire.",
"typeError": "Le destinataire doit être un email, un nom d'utilisateur ou un Gradido ID."
},
"is-not": "Vous ne pouvez pas vous envoyer de Gradido à vous-même",
"requiredField": "Le champ {fieldName} est obligatoire",
"usernmae-regex": "Le nom d'utilisateur doit commencer par une lettre, suivi d'au moins deux caractères alphanumériques.",

View File

@ -146,10 +146,12 @@
"form": {
"amount": "Bedrag",
"at": "op",
"available_after": "Beschikbaar na bevestiging",
"cancel": "Annuleren",
"change": "Wijzigen",
"check_now": "Nu controleren",
"close": "Sluiten",
"current_available": "Momenteel beschikbaar",
"current_balance": "Actueel banksaldo",
"date": "Datum",
"description": "Beschrijving",
@ -158,6 +160,8 @@
"from": "Van",
"generate_now": "Nu genereren",
"lastname": "Achternaam",
"link_amount": "Linkbedrag",
"link_decay_description": "Het linkbedrag wordt geblokkeerd samen met de maximale vergankelijkheid. Nadat de link is ingewisseld, verlopen of verwijderd, wordt het resterende bedrag weer vrijgegeven.",
"memo": "Memo",
"message": "Bericht",
"new_balance": "Nieuw banksaldo na bevestiging",
@ -185,6 +189,11 @@
"to1": "aan",
"validation": {
"gddSendAmount": "Het veld {_field_} moet een getal tussen {min} en {max} met maximaal twee cijfers achter de komma zijn",
"identifier": {
"communityIsReachable": "Community niet gevonden of niet bereikbaar!",
"required": "Ontvanger is een verplicht veld.",
"typeError": "Ontvanger moet een email, een gebruikersnaam of een Gradido ID zijn."
},
"is-not": "Je kunt geen Gradidos aan jezelf overmaken",
"requiredField": "{fieldName} is verplicht",
"usernmae-regex": "De gebruikersnaam moet met een letter beginnen, waarop minimaal twee alfanumerieke tekens dienen te volgen.",

View File

@ -28,10 +28,21 @@ export const memo = string()
export const identifier = string()
.required('form.validation.identifier.required')
.test(
'valid-parts',
'form.validation.identifier.partsError',
(value) => (value.match(/\//g) || []).length <= 1, // allow only one or zero slash
)
.test('valid-identifier', 'form.validation.identifier.typeError', (value) => {
const isEmail = !!EMAIL_REGEX.test(value)
const isUsername = !!value.match(USERNAME_REGEX)
let userPart = value
const parts = value.split('/')
if (parts.length === 2) {
userPart = parts[1]
}
const isEmail = !!EMAIL_REGEX.test(userPart)
const isUsername = !!userPart.match(USERNAME_REGEX)
// TODO: use valibot and rules from shared
const isGradidoId = validateUuid(value) && versionUuid(value) === 4
const isGradidoId = validateUuid(userPart) && versionUuid(userPart) === 4
return isEmail || isUsername || isGradidoId
})

View File

@ -1,3 +1,4 @@
export const DECAY_START_TIME = new Date('2021-05-13T17:46:31Z')
export const SECONDS_PER_YEAR_GREGORIAN_CALENDER = 31556952.0
export const LOG4JS_BASE_CATEGORY_NAME = 'shared'
export const REDEEM_JWT_TOKEN_EXPIRATION = '10m'

View File

@ -0,0 +1 @@
export * from './updateField'

View File

@ -0,0 +1,68 @@
import { updateAllDefinedAndChanged, updateIfDefinedAndChanged } from './updateField'
describe('updateIfDefinedAndChanged', () => {
it('should update field if incoming is different from current', () => {
const current = { field: 'current' }
const incoming = 'incoming'
const result = updateIfDefinedAndChanged(current, 'field', incoming)
expect(result).toBe(true)
expect(current.field).toBe('incoming')
})
it('should not update field if incoming is the same as current', () => {
const current = { field: 'current' }
const incoming = 'current'
const result = updateIfDefinedAndChanged(current, 'field', incoming)
expect(result).toBe(false)
expect(current.field).toBe('current')
})
it('should not update field if incoming is undefined', () => {
const current = { field: 'current' }
const incoming = undefined
const result = updateIfDefinedAndChanged(current, 'field', incoming)
expect(result).toBe(false)
expect(current.field).toBe('current')
})
it('should update field if incoming is null', () => {
type TestEntity = { field: string | null }
const current: TestEntity = { field: 'current' }
const incoming = null
const result = updateIfDefinedAndChanged(current, 'field', incoming)
expect(result).toBe(true)
expect(current.field).toBe(null)
})
})
describe('updateAllDefinedAndChanged', () => {
it('should update all fields if incoming is different from current', () => {
type TestEntity = { field1: string | null, field2: string | null, field3: string | null }
const current: TestEntity = { field1: 'current', field2: 'current', field3: 'current' }
const incoming = { field1: 'incoming', field2: 'incoming', otherField: 'incoming' }
const result = updateAllDefinedAndChanged(current, incoming)
expect(result).toBe(true)
expect(current).toEqual({ field1: 'incoming', field2: 'incoming', field3: 'current' })
})
it('should not update any field if incoming is the same as current', () => {
const current = { field1: 'current', field2: 'current' }
const incoming = { field1: 'current', field2: 'current' }
const result = updateAllDefinedAndChanged(current, incoming)
expect(result).toBe(false)
expect(current).toEqual({ field1: 'current', field2: 'current' })
})
it('should not update any field if incoming is undefined', () => {
const current = { field1: 'current', field2: 'current' }
const incoming = { field1: undefined, field2: undefined }
const result = updateAllDefinedAndChanged(current, incoming)
expect(result).toBe(false)
expect(current).toEqual({ field1: 'current', field2: 'current' })
})
it('should update field if incoming is null', () => {
type TestEntity = { field1: string | null, field2: string | null }
type TestInput = { field1: string | null }
const current: TestEntity = { field1: 'current', field2: 'current' }
const incoming: TestInput = { field1: null }
const result = updateAllDefinedAndChanged(current, incoming)
expect(result).toBe(true)
expect(current).toEqual({ field1: null, field2: 'current' })
})
})

View File

@ -0,0 +1,39 @@
/**
* Updates a field if the incoming value is not undefined and not equal to the current value.
* So basically undefined means don't touch value, null means set value to null.
* @param current The current value of the field.
* @param incoming The incoming value of the field.
* @returns True if the field was updated, false otherwise.
*/
export function updateIfDefinedAndChanged<T, K extends keyof T>(
entity: T,
key: K,
incoming: T[K] | undefined
): boolean {
if (typeof incoming === 'undefined') {
return false
}
// Object.is compare actual values and return true if they are identical
if (Object.is(entity[key], incoming)) {
return false
}
entity[key] = incoming
return true
}
/**
* Check all keys of incoming and if exist on entity, call {@link updateIfDefinedAndChanged}
* to update entity if value isn't undefined and not equal to current value.
* @param entity The entity to update.
* @param incoming The incoming values to update the entity with.
* @returns True if at least one field was updated, false otherwise.
*/
export function updateAllDefinedAndChanged<T extends object>(entity: T, incoming: Partial<T>): boolean {
let updated = false
for (const [key, value] of Object.entries(incoming)) {
if (key in entity && updateIfDefinedAndChanged(entity, key as keyof T, value as T[keyof T])) {
updated = true
}
}
return updated
}

View File

@ -1,5 +1,6 @@
export * from './schema'
export * from './enum'
export * from './helper'
export * from './logic/decay'
export * from './jwt/JWT'
export * from './jwt/payloadtypes/AuthenticationJwtPayloadType'
@ -14,3 +15,4 @@ export * from './jwt/payloadtypes/SendCoinsJwtPayloadType'
export * from './jwt/payloadtypes/SendCoinsResponseJwtPayloadType'
export * from './jwt/payloadtypes/SignedTransferPayloadType'

View File

@ -1,6 +1,6 @@
import { Decimal } from 'decimal.js-light'
import { calculateDecay, decayFormula } from './decay'
import { calculateDecay, compoundInterest, decayFormula, decayFormulaFast } from './decay'
describe('utils/decay', () => {
describe('decayFormula', () => {
@ -10,6 +10,18 @@ describe('utils/decay', () => {
// TODO: toString() was required, we could not compare two decimals
expect(decayFormula(amount, seconds).toString()).toBe('0.999999978035040489732012')
})
it('with large values', () => {
const amount = new Decimal(100.0)
const seconds = 1209600
expect(decayFormula(amount, seconds).toString()).toBe('97.3781030481034505778419')
})
it('with one year', () => {
const amount = new Decimal(100.0)
const seconds = 31556952
expect(decayFormula(amount, seconds).toString()).toBe('49.99999999999999999999999')
})
it('has correct backward calculation', () => {
const amount = new Decimal(1.0)
@ -26,6 +38,40 @@ describe('utils/decay', () => {
expect(decayFormula(amount, seconds).toString()).toBe('1.0')
})
})
describe('decayFormulaFast', () => {
it('work like expected with small values', () => {
const amount = new Decimal(1.0)
const seconds = 1
expect(decayFormulaFast(amount, seconds).toString()).toBe('0.999999978035040489732012')
})
it('work like expected with large values', () => {
const amount = new Decimal(100.0)
const seconds = 1209600
expect(decayFormulaFast(amount, seconds).toString()).toBe('97.3781030481034505778419')
})
it('work like expected with one year', () => {
const amount = new Decimal(100.0)
const seconds = 31556952
expect(decayFormulaFast(amount, seconds).toString()).toBe('50')
})
it('work correct when calculating future decay', () => {
// for example on linked transaction
// if I have amount = 100 GDD and want to know how much GDD I must lock to able to pay
// decay in 14 days (1209600 seconds)
const amount = new Decimal(100.0)
const seconds = 1209600
expect(compoundInterest(amount, seconds).toString()).toBe('102.6924912992003635568199')
// if I lock 102.6924912992003635568199 GDD, I will have 99.99999999999999999999998 GDD after 14 days, not 100% but near enough
expect(decayFormulaFast(compoundInterest(amount, seconds), seconds).toString()).toBe('99.99999999999999999999998')
expect(compoundInterest(amount, seconds).toDecimalPlaces(4).toString()).toBe('102.6925')
// rounded to 4 decimal places it is working like a charm
expect(decayFormulaFast(new Decimal(
compoundInterest(amount, seconds).toDecimalPlaces(4)),
seconds
).toDecimalPlaces(4).toString()).toBe('100')
})
})
it('has base 0.99999997802044727', () => {
const now = new Date()

View File

@ -1,6 +1,6 @@
import { Decimal } from 'decimal.js-light'
import { DECAY_START_TIME } from '../const'
import { DECAY_START_TIME, SECONDS_PER_YEAR_GREGORIAN_CALENDER } from '../const'
Decimal.set({
precision: 25,
@ -26,6 +26,13 @@ export function decayFormula(value: Decimal, seconds: number): Decimal {
)
}
export function decayFormulaFast(value: Decimal, seconds: number): Decimal {
return value.mul(new Decimal(2).pow(new Decimal(-seconds).div(new Decimal(SECONDS_PER_YEAR_GREGORIAN_CALENDER))))
}
export function compoundInterest(value: Decimal, seconds: number): Decimal {
return value.mul(new Decimal(2).pow(new Decimal(seconds).div(new Decimal(SECONDS_PER_YEAR_GREGORIAN_CALENDER))))
}
export function calculateDecay(
amount: Decimal,
from: Date,