Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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 JSON")
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 JSON")
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)
}

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 />;
}

Comment on lines +31 to +34
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix conditional hook usage (violates Rules of Hooks).

Hooks are called after early returns (loading/no plans) and indirectly via useSortPlans. Move hooks to top level.

 export const GenericPlanSelector = <T extends GenericPlanType>({
   onChange,
   value,
   selectProps,
   onPlanChange,
   plans,
   loading,
 }: GenericPlanSelector<T>) => {
-  if (loading) {
-    return <BoxLoading />;
-  }
+  const { incrementPlanWithId } = usePreferredPlans();
+  const sortedPlans = useSortPlans(plans || []);
+
+  if (loading) {
+    return <BoxLoading />;
+  }
 
   if (!plans) {
     return (
       <Box>
         <T keyName="administration-assign-plan-no-plans-to-assign" />
       </Box>
     );
   }
 
-  const { incrementPlanWithId } = usePreferredPlans();
-  const sortedPlans = useSortPlans(plans);

Also applies to: 51-53, 85-95, 100-118

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,42 @@ 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 user's preferred plans and a function to increment a plan's count.
*/
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);
},
Comment on lines +109 to +118
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Bug: newValue spreads the whole response, not the stored map.

You should spread the inner data map; otherwise you persist { data: ..., [planId]: ... }.

   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,
-      };
+      const currentMap = (refetched.data?.data ?? {}) as Record<string, number>;
+      const newValue = {
+        ...currentMap,
+        [planId]: (currentMap[planId] ?? 0) + 1,
+      };
 
       update(newValue);
     },
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
},
return {
preferredPlansLoadable: loadable,
incrementPlanWithId: async (planId: number) => {
const refetched = await loadable.refetch();
const currentMap = (refetched.data?.data ?? {}) as Record<string, number>;
const newValue = {
...currentMap,
[planId]: (currentMap[planId] ?? 0) + 1,
};
update(newValue);
},
};
🤖 Prompt for AI Agents
In
webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx
around lines 107-116, the code spreads refetched.data (the whole response) into
newValue which produces an object like { data: ..., [planId]: ... }; instead,
spread the inner data map so the stored map is updated. Change newValue to
spread refetched.data.data (with safe null checks) and set [planId]: current + 1
so the resulting object is the updated map (e.g. { ...refetched.data.data,
[planId]: current + 1 }) before calling update.

};
}
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
27 changes: 27 additions & 0 deletions webapp/src/hooks/useUserPreferenceStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useApiMutation, useApiQuery } from 'tg.service/http/useQueryApi';
import { UseQueryResult } from 'react-query';

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