From cf87e7ab0e429a9c711ca437f1ede99645f78e91 Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Sat, 4 Oct 2025 09:59:52 +0200 Subject: [PATCH 1/5] use template function updateAllDefinedAndChanged for updating user or community data --- .../src/graphql/resolver/CommunityResolver.ts | 25 ++++--- backend/src/graphql/resolver/UserResolver.ts | 75 +++++++------------ shared/src/helper/index.ts | 1 + shared/src/helper/updateField.test.ts | 68 +++++++++++++++++ shared/src/helper/updateField.ts | 39 ++++++++++ shared/src/index.ts | 2 + 6 files changed, 152 insertions(+), 58 deletions(-) create mode 100644 shared/src/helper/index.ts create mode 100644 shared/src/helper/updateField.test.ts create mode 100644 shared/src/helper/updateField.ts diff --git a/backend/src/graphql/resolver/CommunityResolver.ts b/backend/src/graphql/resolver/CommunityResolver.ts index a46e30144..49e98e8cb 100644 --- a/backend/src/graphql/resolver/CommunityResolver.ts +++ b/backend/src/graphql/resolver/CommunityResolver.ts @@ -1,6 +1,6 @@ import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity, getHomeCommunity } from 'database' import { Arg, Args, Authorized, Mutation, Query, Resolver } from 'type-graphql' -import { IsNull, Not } from 'typeorm' +import { IsNull, Not, Point } from 'typeorm' import { Paginated } from '@arg/Paginated' import { EditCommunityInput } from '@input/EditCommunityInput' @@ -17,6 +17,7 @@ import { getCommunityByIdentifier, getCommunityByUuid, } from './util/communities' +import { updateAllDefinedAndChanged } from 'shared' @Resolver() export class CommunityResolver { @@ -89,16 +90,20 @@ 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) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 045ca7756..3d6fcafd4 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -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 diff --git a/shared/src/helper/index.ts b/shared/src/helper/index.ts new file mode 100644 index 000000000..abfe2c8dc --- /dev/null +++ b/shared/src/helper/index.ts @@ -0,0 +1 @@ +export * from './updateField' \ No newline at end of file diff --git a/shared/src/helper/updateField.test.ts b/shared/src/helper/updateField.test.ts new file mode 100644 index 000000000..4eb6d84e4 --- /dev/null +++ b/shared/src/helper/updateField.test.ts @@ -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' }) + }) +}) + diff --git a/shared/src/helper/updateField.ts b/shared/src/helper/updateField.ts new file mode 100644 index 000000000..0f7008628 --- /dev/null +++ b/shared/src/helper/updateField.ts @@ -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( + 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(entity: T, incoming: Partial): 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 +} \ No newline at end of file diff --git a/shared/src/index.ts b/shared/src/index.ts index dd7965fdc..a9e070d7f 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -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' + From 753a9a5b4664f86aa17692fd6469e064a83fa927 Mon Sep 17 00:00:00 2001 From: einhornimmond Date: Sat, 4 Oct 2025 20:24:42 +0200 Subject: [PATCH 2/5] show decay on linked transaction confirmation --- .../resolver/TransactionLinkResolver.ts | 7 ++- .../GddSend/TransactionConfirmationLink.vue | 28 ++++++++--- frontend/src/constants.js | 3 ++ frontend/src/locales/de.json | 4 ++ frontend/src/locales/en.json | 4 ++ frontend/src/locales/es.json | 4 ++ frontend/src/locales/fr.json | 4 ++ frontend/src/locales/nl.json | 4 ++ shared/src/const/index.ts | 1 + shared/src/logic/decay.test.ts | 48 ++++++++++++++++++- shared/src/logic/decay.ts | 9 +++- 11 files changed, 104 insertions(+), 12 deletions(-) diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 8acbd7b53..faae12e56 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -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) diff --git a/frontend/src/components/GddSend/TransactionConfirmationLink.vue b/frontend/src/components/GddSend/TransactionConfirmationLink.vue index f31d043a0..d964f3e80 100644 --- a/frontend/src/components/GddSend/TransactionConfirmationLink.vue +++ b/frontend/src/components/GddSend/TransactionConfirmationLink.vue @@ -20,20 +20,29 @@ {{ $t('advanced-calculation') }} - {{ $t('form.current_balance') }} + {{ $t('form.current_available') }} {{ $filters.GDD(balance) }} - {{ $t('form.your_amount') }} + {{ $t('form.link_amount') }} - + {{ $filters.GDD(amount * -1) }} - {{ $t('form.new_balance') }} - {{ $filters.GDD(balance - amount) }} + {{ $t('decay.decay') }} + {{ $filters.GDD(amount - blockedAmount) }} + + + {{ $t('form.available_after') }} + {{ $filters.GDD(balance - blockedAmount) }} + + + + {{ $t('form.link_decay_description') }} + @@ -57,6 +66,7 @@