Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package io.tolgee.api.v2.controllers

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import io.tolgee.dtos.request.UserStorageResponse
import io.tolgee.hateoas.userPreferences.UserPreferencesModel
import io.tolgee.security.authentication.AuthenticationFacade
import io.tolgee.security.authentication.BypassEmailVerification
Expand All @@ -17,6 +18,7 @@ import org.springframework.web.bind.annotation.CrossOrigin
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

Expand Down Expand Up @@ -59,4 +61,25 @@ class UserPreferencesController(
organizationRoleService.checkUserCanView(organization.id)
userPreferencesService.setPreferredOrganization(organization, authenticationFacade.authenticatedUserEntity)
}

@GetMapping("/storage/{fieldName}")
@Operation(summary = "Get specific field from user's storage")
fun getStorageField(@PathVariable fieldName: String): UserStorageResponse {
val preferences = userPreferencesService.findOrCreate(authenticationFacade.authenticatedUser.id)
val storage = preferences.storageJson ?: emptyMap()
return UserStorageResponse(storage[fieldName])
}

@PutMapping("/storage/{fieldName}")
@Operation(summary = "Set specific field in user storage")
fun setStorageField(
@PathVariable fieldName: String,
@RequestBody data: Any?
) {
userPreferencesService.setStorageJsonField(
fieldName,
data,
authenticationFacade.authenticatedUserEntity
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,109 @@ class UserPreferencesControllerTest : AuthorizedControllerTest() {
node("preferredOrganizationId").isEqualTo(testData.jirinaOrg.id)
}
}

@Test
fun `stores storage json field`() {
userAccount = testData.franta
performAuthPut("/v2/user-preferences/storage/testField", "testValue").andIsOk
transactionTemplate.execute {
val preferences = userAccountService.findActive(userAccount!!.username)?.preferences
assertThat(preferences?.storageJson).isNotNull
assertThat(preferences?.storageJson?.get("testField")).isEqualTo("testValue")
}
}

@Test
fun `preserves existing storage json data when setting new field`() {
userAccount = testData.franta
// Set first field
performAuthPut("/v2/user-preferences/storage/field1", "value1").andIsOk
// Set second field
performAuthPut("/v2/user-preferences/storage/field2", "value2").andIsOk

transactionTemplate.execute {
val preferences = userAccountService.findActive(userAccount!!.username)?.preferences
assertThat(preferences?.storageJson).isNotNull
assertThat(preferences?.storageJson?.get("field1")).isEqualTo("value1")
assertThat(preferences?.storageJson?.get("field2")).isEqualTo("value2")
}
}

@Test
fun `overwrites existing storage json field`() {
userAccount = testData.franta
// Set initial value
performAuthPut("/v2/user-preferences/storage/testField", "initialValue").andIsOk
// Update the same field
performAuthPut("/v2/user-preferences/storage/testField", "updatedValue").andIsOk

transactionTemplate.execute {
val preferences = userAccountService.findActive(userAccount!!.username)?.preferences
assertThat(preferences?.storageJson).isNotNull
assertThat(preferences?.storageJson?.get("testField")).isEqualTo("updatedValue")
}
}

@Test
fun `handles empty string value`() {
userAccount = testData.franta
performAuthPut("/v2/user-preferences/storage/emptyField", "").andIsOk

transactionTemplate.execute {
val preferences = userAccountService.findActive(userAccount!!.username)?.preferences
assertThat(preferences?.storageJson).isNotNull
assertThat(preferences?.storageJson?.get("emptyField")).isEqualTo("")
}
}

@Test
fun `returns null when field does not exist`() {
userAccount = testData.franta
performAuthGet("/v2/user-preferences/storage/nonExistentField").andIsOk.andAssertThatJson {
node("data").isEqualTo(null)
}
}

@Test
fun `returns stored storage field`() {
userAccount = testData.franta
performAuthPut("/v2/user-preferences/storage/testField", "testValue").andIsOk

performAuthGet("/v2/user-preferences/storage/testField").andIsOk.andAssertThatJson {
node("data").isEqualTo("testValue")
}
}

@Test
fun `returns specific fields with different data types`() {
userAccount = testData.franta
performAuthPut("/v2/user-preferences/storage/stringField", "value").andIsOk
performAuthPut("/v2/user-preferences/storage/numberField", 42).andIsOk
performAuthPut("/v2/user-preferences/storage/booleanField", true).andIsOk

performAuthGet("/v2/user-preferences/storage/stringField").andIsOk.andAssertThatJson {
node("data").isEqualTo("value")
}
performAuthGet("/v2/user-preferences/storage/numberField").andIsOk.andAssertThatJson {
node("data").isEqualTo(42)
}
performAuthGet("/v2/user-preferences/storage/booleanField").andIsOk.andAssertThatJson {
node("data").isEqualTo(true)
}
}

@Test
fun `returns existing field after setting multiple fields`() {
userAccount = testData.franta
performAuthPut("/v2/user-preferences/storage/field1", "value1").andIsOk
performAuthPut("/v2/user-preferences/storage/field2", "value2").andIsOk

// Verify we can retrieve individual fields
performAuthGet("/v2/user-preferences/storage/field1").andIsOk.andAssertThatJson {
node("data").isEqualTo("value1")
}
performAuthGet("/v2/user-preferences/storage/field2").andIsOk.andAssertThatJson {
node("data").isEqualTo("value2")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.tolgee.dtos.request

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import io.swagger.v3.oas.annotations.media.Schema

@JsonIgnoreProperties(ignoreUnknown = true)
data class UserStorageResponse(
@field:Schema(description = "The data stored for the field")
var data: Any? = null,
)
10 changes: 10 additions & 0 deletions backend/data/src/main/kotlin/io/tolgee/model/UserPreferences.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.tolgee.model

import io.hypersistence.utils.hibernate.type.json.JsonBinaryType
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
Expand All @@ -10,6 +11,7 @@ import jakarta.persistence.ManyToOne
import jakarta.persistence.MapsId
import jakarta.persistence.OneToOne
import jakarta.persistence.Table
import org.hibernate.annotations.Type

@Entity
@Table(
Expand All @@ -35,4 +37,12 @@ class UserPreferences(
@Id
@Column(name = "user_account_id")
var id: Long = 0

/**
* Storage of custom user data in JSON format.
* Can be manipulated from the frontend.
*/
@Type(JsonBinaryType::class)
@Column(columnDefinition = "jsonb")
var storageJson: Map<String, Any>? = mutableMapOf()
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,28 @@ class UserPreferencesService(
userPreferencesRepository.save(preferences)
}

/**
* Updates a specific field within the user's stored JSON preferences.
*
* If the user's storage JSON does not exist, it creates one, updates the specified field,
* and saves the changes to the repository.
*/
fun setStorageJsonField(
fieldName: String,
value: Any?,
userAccount: UserAccount,
) {
val preferences = findOrCreate(userAccount.id)
val currentStorage = preferences.storageJson?.toMutableMap() ?: mutableMapOf()
if (value != null) {
currentStorage[fieldName] = value
} else {
currentStorage.remove(fieldName)
}
preferences.storageJson = currentStorage
userPreferencesRepository.save(preferences)
}
Comment on lines +45 to +59
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Avoid lost updates on storageJson (concurrent writes overwrite each other).

This read-modify-write of the whole JSON map can drop parallel changes. Add optimistic locking (version) and wrap in one transaction; or patch the JSONB in-place.

Diff (transactional + no-op when unchanged):

+import org.springframework.transaction.annotation.Transactional
@@
-  fun setStorageJsonField(
+  @Transactional
+  fun setStorageJsonField(
     fieldName: String,
     value: Any?,
     userAccount: UserAccount,
   ) {
     val preferences = findOrCreate(userAccount.id)
     val currentStorage = preferences.storageJson?.toMutableMap() ?: mutableMapOf()
-    if (value != null) {
+    val previous = currentStorage[fieldName]
+    if (previous == value) return
+    if (value != null) {
       currentStorage[fieldName] = value
     } else {
       currentStorage.remove(fieldName)
     }
     preferences.storageJson = currentStorage
     userPreferencesRepository.save(preferences)
   }

Alternative (safer under concurrency): a repository UPDATE using jsonb_set to upsert/remove a single key atomically, avoiding map rewrites. I can draft this if you want.

To verify optimistic locking support:


🏁 Script executed:

#!/bin/bash
# Check for @Version on UserPreferences/AuditModel
rg -nP --type=kt -C3 $'@Version|\\bclass\\s+AuditModel\\b|\\bopen\\s+class\\s+AuditModel\\b|\\bclass\\s+UserPreferences\\b'

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Check for @Version on UserPreferences or AuditModel
rg -nP -g '*.kt' -C3 '@Version|class\s+UserPreferences\b|open\s+class\s+AuditModel\b|class\s+AuditModel\b'

Length of output: 1357


Add optimistic locking or atomic JSONB updates to prevent lost updates

UserPreferences has no @Version field, so concurrent calls to setStorageJsonField will overwrite each other. Wrap the method in @transactional and add a version property (optimistic locking), or switch to an UPDATE using jsonb_set to patch a single key. Example refactor:

+ import org.springframework.transaction.annotation.Transactional
@@
- fun setStorageJsonField(
+ @Transactional
+ fun setStorageJsonField(
@@
+    val previous = currentStorage[fieldName]
+    if (previous == value) return
🤖 Prompt for AI Agents
In
backend/data/src/main/kotlin/io/tolgee/service/security/UserPreferencesService.kt
around lines 45 to 59, concurrent calls to setStorageJsonField can overwrite
each other because UserPreferences lacks optimistic locking and the method is
not transactional; either add an @Version Long field to the UserPreferences
entity and annotate this service method with @Transactional so save() will
fail/retry on concurrent updates, or implement an atomic repository-level update
that issues an UPDATE ... SET storage_json = jsonb_set(storage_json,
'{fieldName}', to_jsonb(:value)) WHERE user_account_id = :id (or equivalent) so
only the single key is patched without reading and writing the whole JSON.
Ensure to handle null removal via jsonb - operator and propagate/handle
OptimisticLockException if using @Version.


fun findOrCreate(userAccountId: Long): UserPreferences {
return tryUntilItDoesntBreakConstraint {
val userAccount = userAccountService.get(userAccountId)
Expand Down
5 changes: 5 additions & 0 deletions backend/data/src/main/resources/db/changelog/schema.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4637,4 +4637,9 @@
<column name="key_id"/>
</createIndex>
</changeSet>
<changeSet author="jenik (generated)" id="1759495253156-1">
<addColumn tableName="user_preferences">
<column name="storage_json" type="JSONB"/>
</addColumn>
</changeSet>
</databaseChangeLog>
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { SearchSelect } from 'tg.component/searchSelect/SearchSelect';
export const CloudPlanSelector: FC<
Omit<
GenericPlanSelector<components['schemas']['AdministrationCloudPlanModel']>,
'plans'
'plans' | 'loading'
> & {
organizationId?: number;
selectProps?: React.ComponentProps<typeof SearchSelect>[`SelectProps`];
Expand All @@ -26,6 +26,7 @@ export const CloudPlanSelector: FC<
<GenericPlanSelector
plans={plansLoadable?.data?._embedded?.plans}
{...props}
loading={plansLoadable.isLoading}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
import React from 'react';
import { T } from '@tolgee/react';
import { Box } from '@mui/material';
import { BoxLoading } from 'tg.component/common/BoxLoading';
import { useUserPreferenceStorage } from 'tg.hooks/useUserPreferenceStorage';

type GenericPlanType = { id: number; name: string };

Expand All @@ -15,6 +17,7 @@ export interface GenericPlanSelector<T extends GenericPlanType> {
onChange?: (value: number) => void;
selectProps?: React.ComponentProps<typeof SearchSelect>[`SelectProps`];
plans?: T[];
loading: boolean;
}

export const GenericPlanSelector = <T extends GenericPlanType>({
Expand All @@ -23,7 +26,12 @@ export const GenericPlanSelector = <T extends GenericPlanType>({
selectProps,
onPlanChange,
plans,
loading,
}: GenericPlanSelector<T>) => {
if (loading) {
return <BoxLoading />;
}

if (!plans) {
return (
<Box>
Expand All @@ -40,12 +48,16 @@ export const GenericPlanSelector = <T extends GenericPlanType>({
} satisfies SelectItem<number>)
);

const { incrementPlanWithId } = usePreferredPlans();
const sortedPlans = useSortPlans(plans);

function handleChange(planId: number) {
if (plans) {
const plan = plans.find((plan) => plan.id === planId);
const plan = sortedPlans.find((plan) => plan.id === planId);
if (plan) {
onChange?.(planId);
onPlanChange?.(plan);
onPlanChange?.(plan as T);
incrementPlanWithId(planId);
}
}
}
Expand All @@ -65,3 +77,44 @@ export const GenericPlanSelector = <T extends GenericPlanType>({
/>
);
};

/**
* Sorts plans by user's preferred plans.
* The purpose of this is to put the user's popular plans to the top.
*/
function useSortPlans(plans: GenericPlanType[]) {
const { preferredPlansLoadable } = usePreferredPlans();

return React.useMemo(() => {
return [...plans].sort(
(a, b) =>
(preferredPlansLoadable.data?.data?.[b.id] || 0) -
(preferredPlansLoadable.data?.data?.[a.id] || 0)
);
}, [plans, preferredPlansLoadable.data]);
}

/**
* Returns a user's preferred plans and a function to increment a plan's count.
*
* The setting is stored on the server in the storageJson filed on the UserPreference entity.
*/
function usePreferredPlans() {
const { loadable, update } = useUserPreferenceStorage(
'billingAdminPreferredPlans'
);

return {
preferredPlansLoadable: loadable,
incrementPlanWithId: async (planId: number) => {
const refetched = await loadable.refetch();
const current = refetched.data?.data[planId] ?? 0;
const newValue = {
...refetched.data,
[planId]: current + 1,
};

update(newValue);
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const SelfHostedEePlanSelector: FC<
GenericPlanSelector<
components['schemas']['SelfHostedEePlanAdministrationModel']
>,
'plans'
'plans' | 'loading'
> & { organizationId?: number }
> = ({ organizationId, ...props }) => {
const plansLoadable = useBillingApiQuery({
Expand All @@ -22,6 +22,7 @@ export const SelfHostedEePlanSelector: FC<
return (
<GenericPlanSelector
plans={plansLoadable.data?._embedded?.plans}
loading={plansLoadable.isLoading}
{...props}
/>
);
Expand Down
33 changes: 33 additions & 0 deletions webapp/src/hooks/useUserPreferenceStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useApiMutation, useApiQuery } from 'tg.service/http/useQueryApi';
import { UseQueryResult } from 'react-query';

/**
* Hook returning the methods to get an update user preference storage.
* The data is stored on the server as a JSONB field.
*
* Use it anywhere you need to store some not-big data for the specific user.
*/
export function useUserPreferenceStorage(fieldName: string) {
const loadable = useApiQuery({
url: '/v2/user-preferences/storage/{fieldName}',
method: 'get',
path: { fieldName },
}) as UseQueryResult<{ data: Record<number, number> }>;

const mutation = useApiMutation({
url: '/v2/user-preferences/storage/{fieldName}',
method: 'put',
});

return {
loadable,
update: (value: Record<string, any>) => {
mutation.mutate({
path: { fieldName },
content: {
'application/json': value,
},
});
},
};
}
Loading
Loading