diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserPreferencesController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserPreferencesController.kt index 7a723db4e2..2a30d624bb 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserPreferencesController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserPreferencesController.kt @@ -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 @@ -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 @@ -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 + ) + } } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserPreferencesControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserPreferencesControllerTest.kt index 3680a47249..0a9f0e7696 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserPreferencesControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserPreferencesControllerTest.kt @@ -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") + } + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/UserStorageResponse.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/UserStorageResponse.kt new file mode 100644 index 0000000000..c7af3d1073 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/UserStorageResponse.kt @@ -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, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/model/UserPreferences.kt b/backend/data/src/main/kotlin/io/tolgee/model/UserPreferences.kt index 1e608d9d1f..2ce4f2f2f5 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserPreferences.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserPreferences.kt @@ -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 @@ -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( @@ -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? = mutableMapOf() } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/UserPreferencesService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/UserPreferencesService.kt index 1af31a92e4..39a8a1ee0d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/UserPreferencesService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/UserPreferencesService.kt @@ -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) diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 63cde8de1f..834b7cbb1c 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -4637,4 +4637,9 @@ + + + + + diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/cloud/fields/CloudPlanSelector.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/cloud/fields/CloudPlanSelector.tsx index a4c00208ed..cd3da0ca50 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/cloud/fields/CloudPlanSelector.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/cloud/fields/CloudPlanSelector.tsx @@ -7,7 +7,7 @@ import { SearchSelect } from 'tg.component/searchSelect/SearchSelect'; export const CloudPlanSelector: FC< Omit< GenericPlanSelector, - 'plans' + 'plans' | 'loading' > & { organizationId?: number; selectProps?: React.ComponentProps[`SelectProps`]; @@ -26,6 +26,7 @@ export const CloudPlanSelector: FC< ); }; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx index 2308ab2338..73b5ac9845 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx @@ -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 }; @@ -15,6 +17,7 @@ export interface GenericPlanSelector { onChange?: (value: number) => void; selectProps?: React.ComponentProps[`SelectProps`]; plans?: T[]; + loading: boolean; } export const GenericPlanSelector = ({ @@ -23,7 +26,12 @@ export const GenericPlanSelector = ({ selectProps, onPlanChange, plans, + loading, }: GenericPlanSelector) => { + if (loading) { + return ; + } + if (!plans) { return ( @@ -40,12 +48,16 @@ export const GenericPlanSelector = ({ } satisfies SelectItem) ); + 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); } } } @@ -65,3 +77,44 @@ export const GenericPlanSelector = ({ /> ); }; + +/** + * 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); + }, + }; +} diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/selfHostedEe/fields/SelfHostedEePlanSelector.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/selfHostedEe/fields/SelfHostedEePlanSelector.tsx index f47eaf1a27..17319243c1 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/selfHostedEe/fields/SelfHostedEePlanSelector.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/selfHostedEe/fields/SelfHostedEePlanSelector.tsx @@ -8,7 +8,7 @@ export const SelfHostedEePlanSelector: FC< GenericPlanSelector< components['schemas']['SelfHostedEePlanAdministrationModel'] >, - 'plans' + 'plans' | 'loading' > & { organizationId?: number } > = ({ organizationId, ...props }) => { const plansLoadable = useBillingApiQuery({ @@ -22,6 +22,7 @@ export const SelfHostedEePlanSelector: FC< return ( ); diff --git a/webapp/src/hooks/useUserPreferenceStorage.ts b/webapp/src/hooks/useUserPreferenceStorage.ts new file mode 100644 index 0000000000..0051948e1e --- /dev/null +++ b/webapp/src/hooks/useUserPreferenceStorage.ts @@ -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 }>; + + const mutation = useApiMutation({ + url: '/v2/user-preferences/storage/{fieldName}', + method: 'put', + }); + + return { + loadable, + update: (value: Record) => { + mutation.mutate({ + path: { fieldName }, + content: { + 'application/json': value, + }, + }); + }, + }; +} diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index d3662e2f53..185ea854ad 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -1019,6 +1019,10 @@ export interface paths { "/v2/user-preferences/set-preferred-organization/{organizationId}": { put: operations["setPreferredOrganization"]; }; + "/v2/user-preferences/storage/{fieldName}": { + get: operations["getStorageField"]; + put: operations["setStorageField"]; + }; "/v2/user-tasks": { get: operations["getTasks_2"]; }; @@ -5968,6 +5972,10 @@ export interface components { /** Format: int64 */ preferredOrganizationId?: number; }; + UserStorageResponse: { + /** @description The data stored for the field */ + data?: unknown; + }; UserTotpDisableRequestDto: { password: string; }; @@ -20256,6 +20264,85 @@ export interface operations { }; }; }; + getStorageField: { + parameters: { + path: { + fieldName: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["UserStorageResponse"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + setStorageField: { + parameters: { + path: { + fieldName: string; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + requestBody: { + content: { + "application/json": { [key: string]: unknown }; + }; + }; + }; getTasks_2: { parameters: { query: {