From 9581f7dfafe3bf9fd84f4fd46439e2c3c3fc67cb Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 3 Jul 2026 00:45:27 -0700 Subject: [PATCH 1/5] MODLD-1040: Update profile settings model to include multiple settings per profile and a label (#546) * Add changes to support multiple named settings per user per profile * Update API specs to work with modified data model * Update methods to work with modified data model and modified API * Add create settings endpoint, update integration tests * Test setProfileSettings * Return saved object, add a delete method * Update tests * Pull out profile existence check to its own method * Require unique profile setting names per user-profile * Remove trailing spaces * Add NotBlank annotation to profile settings name * Add validator unit test, update integration test with constraint tests * Update changelog * Remove stray semi * Update module descriptor with profile settings changes --- descriptors/ModuleDescriptor-template.json | 42 +++- .../data/controller/ProfileController.java | 50 +++- .../model/CreateProfileSettingsRequest.java | 20 ++ .../data/model/entity/ProfileSettings.java | 36 ++- .../model/entity/pk/ProfileSettingsPk.java | 22 -- .../data/repo/ProfileSettingsRepository.java | 12 +- .../profile/ProfileSettingsService.java | 21 +- .../profile/ProfileSettingsServiceImpl.java | 103 ++++++-- .../ProfileSettingsNameUniqueConstraint.java | 25 ++ ...rofileSettingsNameUniquenessValidator.java | 42 ++++ .../resources/ValidationMessages.properties | 1 + .../resources/changelog/changelog-master.xml | 1 + .../scripts/v-3.0.0/metadata/changelog.xml | 7 + .../tables/upgrade_profile_settings_table.sql | 45 ++++ .../swagger.api/mod-linked-data.yaml | 78 +++++- .../schema/profile/customProfileSettings.json | 19 +- .../profile/customProfileSettingsLabel.json | 15 ++ .../customProfileSettingsMetadata.json | 28 +++ .../customProfileSettingsMetadataArray.json | 8 + .../customProfileSettingsResponseDto.json | 15 +- .../profile/customProfileSettingsValues.json | 22 ++ .../data/e2e/endpoint/ProfileSettingsIT.java | 238 ++++++++++++++++-- .../ProfileSettingsServiceImplTest.java | 88 ++++++- ...leSettingsNameUniquenessValidatorTest.java | 85 +++++++ 24 files changed, 912 insertions(+), 111 deletions(-) create mode 100644 src/main/java/org/folio/linked/data/model/CreateProfileSettingsRequest.java delete mode 100644 src/main/java/org/folio/linked/data/model/entity/pk/ProfileSettingsPk.java create mode 100644 src/main/java/org/folio/linked/data/validation/ProfileSettingsNameUniqueConstraint.java create mode 100644 src/main/java/org/folio/linked/data/validation/dto/ProfileSettingsNameUniquenessValidator.java create mode 100644 src/main/resources/changelog/scripts/v-3.0.0/metadata/changelog.xml create mode 100644 src/main/resources/changelog/scripts/v-3.0.0/metadata/tables/upgrade_profile_settings_table.sql create mode 100644 src/main/resources/swagger.api/schema/profile/customProfileSettingsLabel.json create mode 100644 src/main/resources/swagger.api/schema/profile/customProfileSettingsMetadata.json create mode 100644 src/main/resources/swagger.api/schema/profile/customProfileSettingsMetadataArray.json create mode 100644 src/main/resources/swagger.api/schema/profile/customProfileSettingsValues.json create mode 100644 src/test/java/org/folio/linked/data/validation/dto/ProfileSettingsNameUniquenessValidatorTest.java diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 545f2c754..a281e9ab6 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -171,13 +171,28 @@ }, { "methods": [ "GET" ], - "pathPattern": "/linked-data/profile/settings/{id}", + "pathPattern": "/linked-data/profile/{profileId}/settings", + "permissionsRequired": [ "linked-data.profiles.settings.list.get" ] + }, + { + "methods": [ "GET" ], + "pathPattern": "/linked-data/profile/{profileId}/settings/{id}", "permissionsRequired": [ "linked-data.profiles.settings.get" ] }, { "methods": [ "POST" ], - "pathPattern": "/linked-data/profile/settings/{id}", + "pathPattern": "/linked-data/profile/{profileId}/settings/{id}", "permissionsRequired": [ "linked-data.profiles.settings.post" ] + }, + { + "methods": [ "PUT" ], + "pathPattern": "/linked-data/profile/{profileId}/settings/{id}", + "permissionsRequired": [ "linked-data.profiles.settings.put" ] + }, + { + "methods": [ "DELETE" ], + "pathPattern": "/linked-data/profile/{profileId}/settings/{id}", + "permissionsRequired": [ "linked-data.profiles.settings.delete" ] } ] }, @@ -404,15 +419,30 @@ "displayName": "Linked Data: Delete the preferred profile for a resource type for the current user", "description": "Delete the preferred profile for a resource type for the current user" }, + { + "permissionName": "linked-data.profiles.settings.list.get", + "displayName": "Linked Data: List all of the workspace profile settings for the profile for the current user", + "description": "List all of the workspace profile settings for the profile for the current user" + }, { "permissionName": "linked-data.profiles.settings.get", - "displayName": "Linked Data: Get the workspace profile settings for the profile for the current user", - "description": "Get the workspace profile settings for the profile for the current user" + "displayName": "Linked Data: Get workspace profile settings for the profile for the current user", + "description": "Get workspace profile settings for the profile for the current user" }, { "permissionName": "linked-data.profiles.settings.post", - "displayName": "Linked Data: Create or update the profile settings for the profile for the current user", - "description": "Create or update the profile settings for the profile for the current user" + "displayName": "Linked Data: Create profile settings for the profile for the current user", + "description": "Create profile settings for the profile for the current user" + }, + { + "permissionName": "linked-data.profiles.settings.put", + "displayName": "Linked Data: Update workspace profile settings for the profile for the current user", + "description": "Update workspace profile settings for the profile for the current user" + }, + { + "permissionName": "linked-data.profiles.settings.delete", + "displayName": "Linked Data: Delete profile settings for the profile for the current user", + "description": "Delete profile settings for the profile for the current user" }, { "permissionName": "linked-data.resources.rdf.get", diff --git a/src/main/java/org/folio/linked/data/controller/ProfileController.java b/src/main/java/org/folio/linked/data/controller/ProfileController.java index dd2a8beed..7ab645c8b 100644 --- a/src/main/java/org/folio/linked/data/controller/ProfileController.java +++ b/src/main/java/org/folio/linked/data/controller/ProfileController.java @@ -1,11 +1,17 @@ package org.folio.linked.data.controller; +import static org.springframework.http.HttpStatus.CREATED; + +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; +import org.folio.linked.data.domain.dto.CustomProfileSettingsMetadata; import org.folio.linked.data.domain.dto.CustomProfileSettingsRequestDto; import org.folio.linked.data.domain.dto.CustomProfileSettingsResponseDto; import org.folio.linked.data.domain.dto.PreferredProfileRequest; import org.folio.linked.data.domain.dto.ProfileMetadata; +import org.folio.linked.data.model.CreateProfileSettingsRequest; import org.folio.linked.data.rest.resource.ProfileApi; import org.folio.linked.data.service.profile.PreferredProfileService; import org.folio.linked.data.service.profile.ProfileService; @@ -20,6 +26,7 @@ public class ProfileController implements ProfileApi { private final ProfileService profileService; private final PreferredProfileService preferredProfileService; private final ProfileSettingsService profileSettingsService; + private final jakarta.validation.Validator validator; @Override public ResponseEntity getProfileById(Integer profileId) { @@ -49,15 +56,52 @@ public ResponseEntity deletePreferredProfile(String resourceTypeUri) { } @Override - public ResponseEntity getProfileSettings(Integer profileId) { - return ResponseEntity.ok(profileSettingsService.getProfileSettings(profileId)); + public ResponseEntity> getAllProfileSettings(Integer profileId) { + return ResponseEntity.ok(profileSettingsService.getAllProfileSettings(profileId)); + } + + @Override + public ResponseEntity getProfileSettings( + Integer profileId, + Integer profileSettingsId + ) { + return ResponseEntity.ok(profileSettingsService.getProfileSettings(profileId, profileSettingsId)); + } + + @Override + public ResponseEntity createProfileSettings( + Integer profileId, + @Valid CustomProfileSettingsRequestDto profileSettingsRequest) { + var wrappedRequest = new CreateProfileSettingsRequest(profileId, profileSettingsRequest); + validateCreateProfileSettingsRequest(wrappedRequest); + + return ResponseEntity + .status(CREATED) + .body(profileSettingsService.createProfileSettings(profileId, profileSettingsRequest)); } @Override public ResponseEntity setProfileSettings( Integer profileId, + Integer profileSettingsId, CustomProfileSettingsRequestDto profileSettingsRequest) { - profileSettingsService.setProfileSettings(profileId, profileSettingsRequest); + profileSettingsService.setProfileSettings(profileId, profileSettingsId, profileSettingsRequest); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity deleteProfileSettings( + Integer profileId, + Integer profileSettingsId + ) { + profileSettingsService.deleteProfileSettings(profileId, profileSettingsId); return ResponseEntity.noContent().build(); } + + private void validateCreateProfileSettingsRequest(CreateProfileSettingsRequest request) { + var violations = validator.validate(request); + if (!violations.isEmpty()) { + throw new ConstraintViolationException(violations); + } + } } diff --git a/src/main/java/org/folio/linked/data/model/CreateProfileSettingsRequest.java b/src/main/java/org/folio/linked/data/model/CreateProfileSettingsRequest.java new file mode 100644 index 000000000..3e2599e51 --- /dev/null +++ b/src/main/java/org/folio/linked/data/model/CreateProfileSettingsRequest.java @@ -0,0 +1,20 @@ +package org.folio.linked.data.model; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.folio.linked.data.domain.dto.CustomProfileSettingsRequestDto; +import org.folio.linked.data.validation.ProfileSettingsNameUniqueConstraint; + +@ProfileSettingsNameUniqueConstraint +@AllArgsConstructor +@Data +public class CreateProfileSettingsRequest { + @NotNull + private Integer profileId; + + @NotNull + @Valid + private CustomProfileSettingsRequestDto customProfileSettingsRequestDto; +} diff --git a/src/main/java/org/folio/linked/data/model/entity/ProfileSettings.java b/src/main/java/org/folio/linked/data/model/entity/ProfileSettings.java index c818e79a0..36549028c 100644 --- a/src/main/java/org/folio/linked/data/model/entity/ProfileSettings.java +++ b/src/main/java/org/folio/linked/data/model/entity/ProfileSettings.java @@ -1,32 +1,54 @@ package org.folio.linked.data.model.entity; import jakarta.persistence.Column; -import jakarta.persistence.EmbeddedId; import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.MapsId; +import jakarta.persistence.SequenceGenerator; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.util.UUID; import lombok.Data; import lombok.experimental.Accessors; -import org.folio.linked.data.model.entity.pk.ProfileSettingsPk; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; @Data @Entity -@Table(name = "profile_settings") +@Table( + name = "profile_settings", + uniqueConstraints = { + @UniqueConstraint( + name = "unique_profile_settings_name", + columnNames = {"user_id", "profile_id", "name"} + ) + } +) @Accessors(chain = true) public class ProfileSettings { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "profile_settings_id_seq_gen") + @SequenceGenerator( + name = "profile_settings_id_seq_gen", + sequenceName = "profile_settings_id_seq", + initialValue = 1, + allocationSize = 1 + ) + private Integer id; - @EmbeddedId - private ProfileSettingsPk id; + @Column(name = "user_id", nullable = false) + private UUID userId; - @MapsId("profileId") @ManyToOne @JoinColumn(name = "profile_id", nullable = false) private Profile profile; + @Column(name = "name", nullable = false) + private String name; + @JdbcTypeCode(SqlTypes.JSON) @Column(name = "settings", columnDefinition = "jsonb", nullable = false) private String settings; diff --git a/src/main/java/org/folio/linked/data/model/entity/pk/ProfileSettingsPk.java b/src/main/java/org/folio/linked/data/model/entity/pk/ProfileSettingsPk.java deleted file mode 100644 index 034b23829..000000000 --- a/src/main/java/org/folio/linked/data/model/entity/pk/ProfileSettingsPk.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.folio.linked.data.model.entity.pk; - -import jakarta.persistence.Column; -import jakarta.persistence.Embeddable; -import java.io.Serializable; -import java.util.UUID; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Embeddable -@NoArgsConstructor -@AllArgsConstructor -public class ProfileSettingsPk implements Serializable { - - @Column(name = "user_id", nullable = false) - private UUID userId; - - @Column(name = "profile_id", nullable = false) - private Integer profileId; -} diff --git a/src/main/java/org/folio/linked/data/repo/ProfileSettingsRepository.java b/src/main/java/org/folio/linked/data/repo/ProfileSettingsRepository.java index 53ef12443..571f753fb 100644 --- a/src/main/java/org/folio/linked/data/repo/ProfileSettingsRepository.java +++ b/src/main/java/org/folio/linked/data/repo/ProfileSettingsRepository.java @@ -1,11 +1,17 @@ package org.folio.linked.data.repo; +import java.util.List; import java.util.Optional; import java.util.UUID; import org.folio.linked.data.model.entity.ProfileSettings; -import org.folio.linked.data.model.entity.pk.ProfileSettingsPk; import org.springframework.data.repository.CrudRepository; -public interface ProfileSettingsRepository extends CrudRepository { - Optional getByIdUserIdAndIdProfileId(UUID userId, Integer profileId); +public interface ProfileSettingsRepository extends CrudRepository { + List findByUserIdAndProfileId(UUID userId, Integer profileId); + + Optional findByIdAndUserId(Integer id, UUID userId); + + Boolean existsByUserIdAndProfileIdAndName(UUID userId, Integer profileId, String name); + + void deleteByIdAndProfileIdAndUserId(Integer id, Integer profileId, UUID userId); } diff --git a/src/main/java/org/folio/linked/data/service/profile/ProfileSettingsService.java b/src/main/java/org/folio/linked/data/service/profile/ProfileSettingsService.java index 4d59ee7ae..68d9ea0fc 100644 --- a/src/main/java/org/folio/linked/data/service/profile/ProfileSettingsService.java +++ b/src/main/java/org/folio/linked/data/service/profile/ProfileSettingsService.java @@ -1,10 +1,27 @@ package org.folio.linked.data.service.profile; +import java.util.List; +import org.folio.linked.data.domain.dto.CustomProfileSettingsMetadata; import org.folio.linked.data.domain.dto.CustomProfileSettingsRequestDto; import org.folio.linked.data.domain.dto.CustomProfileSettingsResponseDto; public interface ProfileSettingsService { - CustomProfileSettingsResponseDto getProfileSettings(Integer profileId); + List getAllProfileSettings(Integer profileId); - void setProfileSettings(Integer profileId, CustomProfileSettingsRequestDto profileSettingsRequest); + CustomProfileSettingsResponseDto getProfileSettings(Integer profileId, Integer profileSettingsId); + + CustomProfileSettingsMetadata createProfileSettings( + Integer profileId, + CustomProfileSettingsRequestDto profileSettingsRequest + ); + + void setProfileSettings( + Integer profileId, + Integer profileSettingsId, + CustomProfileSettingsRequestDto profileSettingsRequest + ); + + void deleteProfileSettings(Integer profileId, Integer profileSettingsId); + + Boolean nameExistsForProfile(Integer profileId, CustomProfileSettingsRequestDto profileSettingsRequest); } diff --git a/src/main/java/org/folio/linked/data/service/profile/ProfileSettingsServiceImpl.java b/src/main/java/org/folio/linked/data/service/profile/ProfileSettingsServiceImpl.java index 0d67d3861..8f4fb865f 100644 --- a/src/main/java/org/folio/linked/data/service/profile/ProfileSettingsServiceImpl.java +++ b/src/main/java/org/folio/linked/data/service/profile/ProfileSettingsServiceImpl.java @@ -2,16 +2,17 @@ import static org.folio.linked.data.util.JsonUtils.JSON_MAPPER; +import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.folio.linked.data.domain.dto.CustomProfileSettings; +import org.folio.linked.data.domain.dto.CustomProfileSettingsMetadata; import org.folio.linked.data.domain.dto.CustomProfileSettingsRequestDto; import org.folio.linked.data.domain.dto.CustomProfileSettingsResponseDto; import org.folio.linked.data.exception.RequestProcessingExceptionBuilder; import org.folio.linked.data.model.entity.Profile; import org.folio.linked.data.model.entity.ProfileSettings; -import org.folio.linked.data.model.entity.pk.ProfileSettingsPk; import org.folio.linked.data.repo.ProfileRepository; import org.folio.linked.data.repo.ProfileSettingsRepository; import org.folio.spring.FolioExecutionContext; @@ -31,55 +32,119 @@ public class ProfileSettingsServiceImpl implements ProfileSettingsService { @Override @Transactional(readOnly = true) - public CustomProfileSettingsResponseDto getProfileSettings(Integer profileId) { + public List getAllProfileSettings(Integer profileId) { var userId = executionContext.getUserId(); - profileRepository.findById(profileId) - .orElseThrow(() -> exceptionBuilder.notFoundLdResourceByIdException("Profile", String.valueOf(profileId))); - var settings = profileSettingsRepository.getByIdUserIdAndIdProfileId(userId, profileId); + getProfile(profileId); + var settings = profileSettingsRepository.findByUserIdAndProfileId(userId, profileId); + return settings.stream() + .map(profileSettings -> toMetadata(profileId, profileSettings)) + .toList(); + } + + @Override + @Transactional(readOnly = true) + public CustomProfileSettingsResponseDto getProfileSettings(Integer profileId, Integer profileSettingsId) { + var userId = executionContext.getUserId(); + getProfile(profileId); + var settings = profileSettingsRepository.findByIdAndUserId(profileSettingsId, userId); return settings.map(profileSettings -> toDto(profileId, userId, profileSettings)) - .orElseGet(() -> defaultToProfile(profileId)); + .orElseGet(() -> defaultToProfile(profileId, profileSettingsId)); } @Override - public void setProfileSettings(Integer profileId, CustomProfileSettingsRequestDto profileSettingsRequest) { + public CustomProfileSettingsMetadata createProfileSettings( + Integer profileId, + CustomProfileSettingsRequestDto profileSettingsRequest + ) { var userId = executionContext.getUserId(); - var profile = profileRepository.findById(profileId) - .orElseThrow(() -> exceptionBuilder.notFoundLdResourceByIdException("Profile", String.valueOf(profileId))); + var profile = getProfile(profileId); + try { + var settings = toEntity(profile, null, userId, profileSettingsRequest); + var saved = profileSettingsRepository.save(settings); + return toMetadata(profileId, saved); + } catch (JacksonException e) { + throw exceptionBuilder.badRequestException("Could not process settings", String.valueOf(profileId)); + } + } + + @Override + public void setProfileSettings( + Integer profileId, + Integer profileSettingsId, + CustomProfileSettingsRequestDto profileSettingsRequest + ) { + var userId = executionContext.getUserId(); + var profile = getProfile(profileId); try { - var settings = toEntity(profile, userId, profileSettingsRequest); + var settings = toEntity(profile, profileSettingsId, userId, profileSettingsRequest); profileSettingsRepository.save(settings); } catch (JacksonException e) { throw exceptionBuilder.badRequestException("Could not process settings", String.valueOf(profileId)); } } + @Override + public void deleteProfileSettings(Integer profileId, Integer profileSettingsId) { + var userId = executionContext.getUserId(); + getProfile(profileId); + profileSettingsRepository.deleteByIdAndProfileIdAndUserId(profileSettingsId, profileId, userId); + } + + @Override + public Boolean nameExistsForProfile(Integer profileId, CustomProfileSettingsRequestDto profileSettingsRequest) { + var userId = executionContext.getUserId(); + return profileSettingsRepository.existsByUserIdAndProfileIdAndName( + userId, profileId, profileSettingsRequest.getName()); + } + + private Profile getProfile(Integer profileId) { + return profileRepository.findById(profileId) + .orElseThrow(() -> exceptionBuilder.notFoundLdResourceByIdException("Profile", String.valueOf(profileId))); + } + /** * In any case where the custom profile settings are not available, whether because * they haven't been set, they've been corrupted, or something else is wrong internally, * just return inactive settings. In all cases, this should lead to the profile's default * being used, whether for settings editing or editor rendering. */ - private CustomProfileSettingsResponseDto defaultToProfile(Integer profileId) { - return new CustomProfileSettingsResponseDto(profileId, false, null); + private CustomProfileSettingsResponseDto defaultToProfile(Integer profileId, Integer profileSettingsId) { + return new CustomProfileSettingsResponseDto(false, null, "(defaults)", profileSettingsId, profileId); + } + + private CustomProfileSettingsMetadata toMetadata( + Integer profileId, + ProfileSettings settings + ) { + return new CustomProfileSettingsMetadata(settings.getId(), profileId, settings.getName()); } private CustomProfileSettingsResponseDto toDto(Integer profileId, UUID userId, ProfileSettings settings) { try { var customProfileSettings = JSON_MAPPER.readValue(settings.getSettings(), CustomProfileSettings.class); return new CustomProfileSettingsResponseDto( - profileId, customProfileSettings.getActive(), - customProfileSettings.getChildren()); + customProfileSettings.getChildren(), + settings.getName(), + settings.getId(), + profileId); } catch (JacksonException e) { - log.error("Could not read stored profile settings (user: {}, profile: {}) - default to profile", - userId, profileId); - return defaultToProfile(profileId); + log.error("Could not read stored profile settings (user: {}, profile: {}, settings: {}) - default to profile", + userId, profileId, settings.getId()); + return defaultToProfile(settings.getId(), profileId); } } - private ProfileSettings toEntity(Profile profile, UUID userId, CustomProfileSettingsRequestDto requestDto) { + private ProfileSettings toEntity( + Profile profile, + Integer profileSettingsId, + UUID userId, + CustomProfileSettingsRequestDto requestDto + ) { return new ProfileSettings() - .setId(new ProfileSettingsPk(userId, profile.getId())) + .setId(profileSettingsId) + .setName(requestDto.getName()) + .setUserId(userId) .setProfile(profile) .setSettings(JSON_MAPPER.writeValueAsString(requestDto)); } diff --git a/src/main/java/org/folio/linked/data/validation/ProfileSettingsNameUniqueConstraint.java b/src/main/java/org/folio/linked/data/validation/ProfileSettingsNameUniqueConstraint.java new file mode 100644 index 000000000..780c5ad83 --- /dev/null +++ b/src/main/java/org/folio/linked/data/validation/ProfileSettingsNameUniqueConstraint.java @@ -0,0 +1,25 @@ +package org.folio.linked.data.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.folio.linked.data.validation.dto.ProfileSettingsNameUniquenessValidator; + +@Documented +@SuppressWarnings("javaarchitecture:S7091") +@Constraint(validatedBy = ProfileSettingsNameUniquenessValidator.class) +@Target({ElementType.FIELD, ElementType.TYPE, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ProfileSettingsNameUniqueConstraint { + + String message() default "{profileSettingsNameUniqueConstraint.message}"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/src/main/java/org/folio/linked/data/validation/dto/ProfileSettingsNameUniquenessValidator.java b/src/main/java/org/folio/linked/data/validation/dto/ProfileSettingsNameUniquenessValidator.java new file mode 100644 index 000000000..7d9e5f897 --- /dev/null +++ b/src/main/java/org/folio/linked/data/validation/dto/ProfileSettingsNameUniquenessValidator.java @@ -0,0 +1,42 @@ +package org.folio.linked.data.validation.dto; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.folio.linked.data.model.CreateProfileSettingsRequest; +import org.folio.linked.data.service.profile.ProfileSettingsService; +import org.folio.linked.data.validation.ProfileSettingsNameUniqueConstraint; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Log4j2 +@SuppressWarnings("javaarchitecture:S7091") +public class ProfileSettingsNameUniquenessValidator + implements ConstraintValidator { + + private final ProfileSettingsService profileSettingsService; + + @Override + public boolean isValid( + CreateProfileSettingsRequest createProfileSettingsRequest, ConstraintValidatorContext context) { + if (createProfileSettingsRequest.getProfileId() == null + || createProfileSettingsRequest.getCustomProfileSettingsRequestDto() == null) { + return true; + } + + var isDuplicate = profileSettingsService.nameExistsForProfile( + createProfileSettingsRequest.getProfileId(), createProfileSettingsRequest.getCustomProfileSettingsRequestDto()); + + if (isDuplicate.booleanValue()) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate()) + .addPropertyNode("customProfileSettingsRequestDto") + .addPropertyNode("name") + .addConstraintViolation(); + } + + return !isDuplicate; + } +} diff --git a/src/main/resources/ValidationMessages.properties b/src/main/resources/ValidationMessages.properties index 5b6a4a942..58b0c891a 100644 --- a/src/main/resources/ValidationMessages.properties +++ b/src/main/resources/ValidationMessages.properties @@ -4,3 +4,4 @@ lccnUniqueConstraint.message=lccn_not_unique partOfSeriesTitleConstraint.message=required_series_title resourceTypeConstraint.message=wrong_resource_type instanceSingleWorkConstraint.message=instance_no_single_work +profileSettingsNameUniqueConstraint.message=profile_settings_name_not_unique diff --git a/src/main/resources/changelog/changelog-master.xml b/src/main/resources/changelog/changelog-master.xml index e48c578b1..b69c2960a 100644 --- a/src/main/resources/changelog/changelog-master.xml +++ b/src/main/resources/changelog/changelog-master.xml @@ -6,4 +6,5 @@ + diff --git a/src/main/resources/changelog/scripts/v-3.0.0/metadata/changelog.xml b/src/main/resources/changelog/scripts/v-3.0.0/metadata/changelog.xml new file mode 100644 index 000000000..3b7413db1 --- /dev/null +++ b/src/main/resources/changelog/scripts/v-3.0.0/metadata/changelog.xml @@ -0,0 +1,7 @@ + + + + diff --git a/src/main/resources/changelog/scripts/v-3.0.0/metadata/tables/upgrade_profile_settings_table.sql b/src/main/resources/changelog/scripts/v-3.0.0/metadata/tables/upgrade_profile_settings_table.sql new file mode 100644 index 000000000..0e48b4457 --- /dev/null +++ b/src/main/resources/changelog/scripts/v-3.0.0/metadata/tables/upgrade_profile_settings_table.sql @@ -0,0 +1,45 @@ +--liquibase formatted sql + +--changeset upgrade_profile_settings_table dbms:postgresql + +-- Create a sequence for the primary ID +create sequence profile_settings_id_seq + start with 1; + +-- Add new primary key column +alter table profile_settings + add column id int not null default nextval('profile_settings_id_seq'); + +-- Remove previous primary key +alter table profile_settings + drop constraint profile_settings_pkey; + +-- Add primary key constraint +alter table profile_settings + add constraint profile_settings_pkey primary key (id); + +-- Recreate old PK index, still relevant +create index if not exists profile_settings_user_profile + on profile_settings(user_id, profile_id); + +-- Add new name column +alter table profile_settings + add column name text; + +-- Fill in placeholder value +update profile_settings + set name = '(unnamed)'; + +-- Set name to not null, required field +alter table profile_settings + alter column name set not null; + +-- Make name unique for each user-profile +alter table profile_settings + add constraint unique_profile_settings_name unique (user_id, profile_id, name); + +--rollback alter table profile_settings drop column name; +--rollback drop index profile_settings_user_profile; +--rollback alter table profile_settings drop constraint profile_settings_id_pk; +--rollback alter table profile_settings drop column id; +--rollback alter table profile_settings add constraint profile_settings_pkey primary key (user_id, profile_id); diff --git a/src/main/resources/swagger.api/mod-linked-data.yaml b/src/main/resources/swagger.api/mod-linked-data.yaml index e0e600abf..5240f9d2d 100644 --- a/src/main/resources/swagger.api/mod-linked-data.yaml +++ b/src/main/resources/swagger.api/mod-linked-data.yaml @@ -399,7 +399,53 @@ paths: '500': $ref: '#/components/responses/internalServerErrorResponse' - /linked-data/profile/settings/{profileId}: + /linked-data/profile/{profileId}/settings: + get: + operationId: getAllProfileSettings + tags: + - profile + description: Get the workspace profile settings metadata list for a profile for the current user + parameters: + - $ref: '#/components/parameters/profileId' + responses: + '200': + description: Workspace profile settings metadata list for the current user + content: + application/json: + schema: + $ref: "schema/profile/customProfileSettingsMetadataArray.json" + '404': + description: No profile found with a given profileId + '500': + $ref: '#/components/responses/internalServerErrorResponse' + + post: + operationId: createProfileSettings + tags: + - profile + description: Create the workspace profile settings for a profile for the current user + parameters: + - $ref: '#/components/parameters/profileId' + requestBody: + content: + application/json: + schema: + $ref: "schema/profile/customProfileSettingsRequestDto.json" + responses: + '201': + description: Workspace profile settings created successfully + content: + application/json: + schema: + $ref: "schema/profile/customProfileSettingsMetadata.json" + '400': + $ref: '#/components/responses/badRequestResponse' + '404': + description: No profile found with a given profileId + '500': + $ref: '#/components/responses/internalServerErrorResponse' + + /linked-data/profile/{profileId}/settings/{profileSettingsId}: get: operationId: getProfileSettings tags: @@ -407,6 +453,7 @@ paths: description: Get the workspace profile settings for a profile for the current user parameters: - $ref: '#/components/parameters/profileId' + - $ref: '#/components/parameters/profileSettingsId' responses: '200': description: Workspace profile settings for the current user @@ -419,13 +466,14 @@ paths: '500': $ref: '#/components/responses/internalServerErrorResponse' - post: + put: operationId: setProfileSettings tags: - profile - description: Create or update the workspace profile settings for a profile for the current user + description: Update the workspace profile settings for a profile for the current user parameters: - $ref: '#/components/parameters/profileId' + - $ref: '#/components/parameters/profileSettingsId' requestBody: content: application/json: @@ -441,6 +489,22 @@ paths: '500': $ref: '#/components/responses/internalServerErrorResponse' + delete: + operationId: deleteProfileSettings + tags: + - profile + description: Delete a saved workspace profile settings for a profile for the current user + parameters: + - $ref: '#/components/parameters/profileId' + - $ref: '#/components/parameters/profileSettingsId' + responses: + '204': + description: Saved workspace profile settings deleted successfully + '400': + $ref: '#/components/responses/badRequestResponse' + '500': + $ref: '#/components/responses/internalServerErrorResponse' + /linked-data/resource/{id}/graph: get: operationId: getResourceGraphById @@ -639,6 +703,14 @@ components: schema: type: integer format: int16 + profileSettingsId: + name: profileSettingsId + in: path + required: true + description: ID of the profile settings + schema: + type: integer + format: int16 profileIdQuery: name: profileId in: query diff --git a/src/main/resources/swagger.api/schema/profile/customProfileSettings.json b/src/main/resources/swagger.api/schema/profile/customProfileSettings.json index edfda40a8..208af4994 100644 --- a/src/main/resources/swagger.api/schema/profile/customProfileSettings.json +++ b/src/main/resources/swagger.api/schema/profile/customProfileSettings.json @@ -2,21 +2,12 @@ "$schema": "http://json-schema.org/draft-04/schema#", "description": "Workspace custom profile settings", "type": "object", - "properties": { - "active": { - "type": "boolean", - "description": "True if settings are intended to be applied, false if intended to be ignored" + "allOf": [ + { + "$ref": "customProfileSettingsLabel.json" }, - "children": { - "type": "array", - "description": "Profile block children", - "items": { - "$ref": "customProfileSettingsChild.json" - } + { + "$ref": "customProfileSettingsValues.json" } - }, - "required": [ - "active", - "children" ] } diff --git a/src/main/resources/swagger.api/schema/profile/customProfileSettingsLabel.json b/src/main/resources/swagger.api/schema/profile/customProfileSettingsLabel.json new file mode 100644 index 000000000..c6c0b1f38 --- /dev/null +++ b/src/main/resources/swagger.api/schema/profile/customProfileSettingsLabel.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Custom profile settings label", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the workspace custom profile settings", + "x-field-extra-annotation": "@jakarta.validation.constraints.NotBlank" + } + }, + "required": [ + "name" + ] +} diff --git a/src/main/resources/swagger.api/schema/profile/customProfileSettingsMetadata.json b/src/main/resources/swagger.api/schema/profile/customProfileSettingsMetadata.json new file mode 100644 index 000000000..583c70b4b --- /dev/null +++ b/src/main/resources/swagger.api/schema/profile/customProfileSettingsMetadata.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Custom profile settings metadata", + "type": "object", + "allOf": [ + { + "$ref": "customProfileSettingsLabel.json" + }, + { + "properties": { + "id": { + "type": "integer", + "format": "int16", + "description": "ID of the custom profile settings" + }, + "profileId": { + "type": "integer", + "format": "int16", + "description": "ID of the profile" + } + }, + "required": [ + "id", + "profileId" + ] + } + ] +} diff --git a/src/main/resources/swagger.api/schema/profile/customProfileSettingsMetadataArray.json b/src/main/resources/swagger.api/schema/profile/customProfileSettingsMetadataArray.json new file mode 100644 index 000000000..2e6cebda9 --- /dev/null +++ b/src/main/resources/swagger.api/schema/profile/customProfileSettingsMetadataArray.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Array of custom profile settings metadata", + "type": "array", + "items": { + "$ref": "customProfileSettingsMetadata.json" + } +} diff --git a/src/main/resources/swagger.api/schema/profile/customProfileSettingsResponseDto.json b/src/main/resources/swagger.api/schema/profile/customProfileSettingsResponseDto.json index 7db3bbd88..9082c8f13 100644 --- a/src/main/resources/swagger.api/schema/profile/customProfileSettingsResponseDto.json +++ b/src/main/resources/swagger.api/schema/profile/customProfileSettingsResponseDto.json @@ -1,22 +1,13 @@ { "$schema": "http://json-schema.org/draft-04/schema#", "description": "Workspace custom profile settings response DTO", + "type": "object", "allOf": [ { - "$ref": "customProfileSettings.json" + "$ref": "customProfileSettingsValues.json" }, { - "type": "object", - "properties": { - "profileId": { - "type": "integer", - "format": "int16", - "description": "ID of the profile" - } - }, - "required": [ - "profileId" - ] + "$ref": "customProfileSettingsMetadata.json" } ] } diff --git a/src/main/resources/swagger.api/schema/profile/customProfileSettingsValues.json b/src/main/resources/swagger.api/schema/profile/customProfileSettingsValues.json new file mode 100644 index 000000000..edfda40a8 --- /dev/null +++ b/src/main/resources/swagger.api/schema/profile/customProfileSettingsValues.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Workspace custom profile settings", + "type": "object", + "properties": { + "active": { + "type": "boolean", + "description": "True if settings are intended to be applied, false if intended to be ignored" + }, + "children": { + "type": "array", + "description": "Profile block children", + "items": { + "$ref": "customProfileSettingsChild.json" + } + } + }, + "required": [ + "active", + "children" + ] +} diff --git a/src/test/java/org/folio/linked/data/e2e/endpoint/ProfileSettingsIT.java b/src/test/java/org/folio/linked/data/e2e/endpoint/ProfileSettingsIT.java index 77366a6b8..4ada2c990 100644 --- a/src/test/java/org/folio/linked/data/e2e/endpoint/ProfileSettingsIT.java +++ b/src/test/java/org/folio/linked/data/e2e/endpoint/ProfileSettingsIT.java @@ -1,16 +1,22 @@ package org.folio.linked.data.e2e.endpoint; import static java.util.UUID.randomUUID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.folio.linked.data.test.TestUtil.TEST_JSON_MAPPER; import static org.folio.linked.data.test.TestUtil.defaultHeadersWithUserId; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.jayway.jsonpath.JsonPath; +import org.folio.linked.data.domain.dto.ErrorResponse; import org.folio.linked.data.e2e.base.IntegrationTest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -20,13 +26,29 @@ @IntegrationTest class ProfileSettingsIT { - private static final String PROFILE_SETTINGS_URL = "/linked-data/profile/settings/"; + private static final String PROFILE_URL = "/linked-data/profile/"; + private static final String SETTINGS_PATH = "/settings"; @Autowired private MockMvc mockMvc; @Autowired private Environment env; + @Test + void shouldBeEmptyForProfileWithNoSettings() throws Exception { + // given + var headers = defaultHeadersWithUserId(env, randomUUID().toString()); + headers.setContentType(APPLICATION_JSON); + + // when, then + var getAllRequest = get(PROFILE_URL + "2" + SETTINGS_PATH) + .headers(headers); + mockMvc.perform(getAllRequest) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$").isEmpty()); + } + @Test void shouldBeNotFoundForUnknownProfile() throws Exception { // given @@ -34,7 +56,7 @@ void shouldBeNotFoundForUnknownProfile() throws Exception { headers.setContentType(APPLICATION_JSON); // when, then - var getRequest = get(PROFILE_SETTINGS_URL + "9999999") + var getRequest = get(PROFILE_URL + "9999999" + SETTINGS_PATH + "/1") .headers(headers); mockMvc.perform(getRequest) .andExpect(status().isNotFound()); @@ -47,7 +69,7 @@ void shouldBeInactiveSettingsWhenNotSet() throws Exception { headers.setContentType(APPLICATION_JSON); // when - var getRequest = get(PROFILE_SETTINGS_URL + "2") + var getRequest = get(PROFILE_URL + "2" + SETTINGS_PATH + "/1") .headers(headers); // then @@ -58,29 +80,33 @@ void shouldBeInactiveSettingsWhenNotSet() throws Exception { } @Test - void shouldSetProfileSettings() throws Exception { + void shouldCreateProfileSettings() throws Exception { // given var headers = defaultHeadersWithUserId(env, randomUUID().toString()); headers.setContentType(APPLICATION_JSON); - var postRequest = post(PROFILE_SETTINGS_URL + "2") + var postRequest = post(PROFILE_URL + "2" + SETTINGS_PATH) .headers(headers) .content(""" { - "active": true, - "children": [ - { - "id": "Work:Monograph:Title", - "visible": true, - "order": 1 - } - ] + "name": "My settings", + "active": true, + "children": [ + { + "id": "Work:Monograph:Title", + "visible": true, + "order": 1 + } + ] }"""); - mockMvc.perform(postRequest) - .andExpect(status().isNoContent()); + var postResult = mockMvc.perform(postRequest) + .andExpect(status().isCreated()) + .andReturn(); + var postResultBody = postResult.getResponse().getContentAsString(); + var settingsId = JsonPath.read(postResultBody, "$.id"); // when - var getRequest = get(PROFILE_SETTINGS_URL + "2") + var getRequest = get(PROFILE_URL + "2" + SETTINGS_PATH + "/" + settingsId) .headers(headers); // then @@ -90,4 +116,184 @@ void shouldSetProfileSettings() throws Exception { .andExpect(jsonPath("$.active", is(true))) .andExpect(jsonPath("$.children.length()", equalTo(1))); } + + @Test + void shouldRejectCreatingDuplicateNamedSettings() throws Exception { + // given + var headers = defaultHeadersWithUserId(env, randomUUID().toString()); + headers.setContentType(APPLICATION_JSON); + + var postRequest = post(PROFILE_URL + "2" + SETTINGS_PATH) + .headers(headers) + .content(""" + { + "name": "My settings", + "active": true, + "children": [ + { + "id": "Work:Monograph:Title", + "visible": true, + "order": 1 + } + ] + }"""); + mockMvc.perform(postRequest) + .andExpect(status().isCreated()); + + // when + var duplicatePostRequest = post(PROFILE_URL + "2" + SETTINGS_PATH) + .headers(headers) + .content(""" + { + "name": "My settings", + "active": true, + "children": [ + { + "id": "Work:Monograph:Title", + "visible": true, + "order": 1 + }, + { + "id": "Work:Monograph:OtherTitleInformation", + "visible": true, + "order": 2 + } + ] + } + """); + var response = mockMvc.perform(duplicatePostRequest) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andReturn().getResponse().getContentAsString(); + + var errorResponse = TEST_JSON_MAPPER.readValue(response, ErrorResponse.class); + assertThat(errorResponse.getErrors()) + .extracting("code") + .contains("profile_settings_name_not_unique"); + } + + @Test + void shouldRejectBlankNamedSettings() throws Exception { + // given + var headers = defaultHeadersWithUserId(env, randomUUID().toString()); + headers.setContentType(APPLICATION_JSON); + + var postRequest = post(PROFILE_URL + "2" + SETTINGS_PATH) + .headers(headers) + .content(""" + { + "name": "", + "active": true, + "children": [ + { + "id": "Work:Monograph:Title", + "visible": true, + "order": 1 + } + ] + }"""); + var response = mockMvc.perform(postRequest) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andReturn().getResponse().getContentAsString(); + + var errorResponse = TEST_JSON_MAPPER.readValue(response, ErrorResponse.class); + assertThat(errorResponse.getErrors()) + .extracting("code") + .contains("must not be blank"); + } + + @Test + void shouldSetProfileSettings() throws Exception { + // given + var headers = defaultHeadersWithUserId(env, randomUUID().toString()); + headers.setContentType(APPLICATION_JSON); + + var postRequest = post(PROFILE_URL + "2" + SETTINGS_PATH) + .headers(headers) + .content(""" + { + "name": "My settings", + "active": true, + "children": [ + { + "id": "Work:Monograph:Title", + "visible": true, + "order": 1 + } + ] + }"""); + var postResult = mockMvc.perform(postRequest) + .andExpect(status().isCreated()) + .andReturn(); + var postResultBody = postResult.getResponse().getContentAsString(); + var settingsId = JsonPath.read(postResultBody, "$.id"); + + // when + var putRequest = put(PROFILE_URL + "2" + SETTINGS_PATH + "/" + settingsId) + .headers(headers) + .content(""" + { + "name": "My settings", + "active": true, + "children": [ + { + "id": "Work:Monograph:Title", + "visible": true, + "order": 1 + }, + { + "id": "Work:Monograph:OtherTitleInformation", + "visible": true, + "order": 2 + } + ] + } + """); + mockMvc.perform(putRequest) + .andExpect(status().isNoContent()); + + var getRequest = get(PROFILE_URL + "2" + SETTINGS_PATH + "/" + settingsId) + .headers(headers); + + // then + mockMvc.perform(getRequest) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.active", is(true))) + .andExpect(jsonPath("$.children.length()", equalTo(2))); + } + + @Test + void shouldDeleteProfileSettings() throws Exception { + // given + var headers = defaultHeadersWithUserId(env, randomUUID().toString()); + headers.setContentType(APPLICATION_JSON); + + var postRequest = post(PROFILE_URL + "2" + SETTINGS_PATH) + .headers(headers) + .content(""" + { + "name": "My settings", + "active": true, + "children": [ + { + "id": "Work:Monograph:Title", + "visible": true, + "order": 1 + } + ] + }"""); + var postResult = mockMvc.perform(postRequest) + .andExpect(status().isCreated()) + .andReturn(); + var postResultBody = postResult.getResponse().getContentAsString(); + var settingsId = JsonPath.read(postResultBody, "$.id"); + + // when + var deleteRequest = delete(PROFILE_URL + "2" + SETTINGS_PATH + "/" + settingsId) + .headers(headers); + mockMvc.perform(deleteRequest) + .andExpect(status().isNoContent()); + } } diff --git a/src/test/java/org/folio/linked/data/service/profile/ProfileSettingsServiceImplTest.java b/src/test/java/org/folio/linked/data/service/profile/ProfileSettingsServiceImplTest.java index bff1b42ca..bb633fdb8 100644 --- a/src/test/java/org/folio/linked/data/service/profile/ProfileSettingsServiceImplTest.java +++ b/src/test/java/org/folio/linked/data/service/profile/ProfileSettingsServiceImplTest.java @@ -38,19 +38,56 @@ class ProfileSettingsServiceImplTest { @Mock private FolioExecutionContext executionContext; + @Test + void getAllProfileSettings_shouldReturnEmptyList_ifNoSettings() { + // given + var userId = UUID.randomUUID(); + doReturn(userId).when(executionContext).getUserId(); + var id = 1; + var profile = new Profile(); + profile.setId(id); + when(profileRepository.findById(id)).thenReturn(Optional.of(profile)); + + // when + var settings = profileSettingsService.getAllProfileSettings(id); + + // then + assertThat(settings).isEmpty(); + } + + @Test + void getAllProfileSettings_shouldThrowNotFound_ifNoSuchProfile() { + // given + var userId = UUID.randomUUID(); + doReturn(userId).when(executionContext).getUserId(); + var id = 1; + when(profileRepository.findById(id)).thenReturn(Optional.empty()); + when(exceptionBuilder.notFoundLdResourceByIdException(anyString(), anyString())) + .thenReturn(emptyRequestProcessingException()); + + // when + var thrown = assertThrows(RequestProcessingException.class, + () -> profileSettingsService.getAllProfileSettings(id)); + + // then + assertThat(thrown.getClass()).isEqualTo(RequestProcessingException.class); + assertThat(thrown.getMessage()).isEmpty(); + } + @Test void getProfileSettings_shouldThrowNotFound_ifNoSuchProfile() { // given var userId = UUID.randomUUID(); doReturn(userId).when(executionContext).getUserId(); var id = 1; + var settingsId = 1; when(profileRepository.findById(id)).thenReturn(Optional.empty()); when(exceptionBuilder.notFoundLdResourceByIdException(anyString(), anyString())) .thenReturn(emptyRequestProcessingException()); // when var thrown = assertThrows(RequestProcessingException.class, - () -> profileSettingsService.getProfileSettings(id)); + () -> profileSettingsService.getProfileSettings(id, settingsId)); // then assertThat(thrown.getClass()).isEqualTo(RequestProcessingException.class); @@ -66,33 +103,76 @@ void getProfileSettings_shouldReturnInactiveSettings_ifNoSuchSettings() { var profile = new Profile(); profile.setId(id); when(profileRepository.findById(id)).thenReturn(Optional.of(profile)); + var profileSettingsId = 2; // when - var settings = profileSettingsService.getProfileSettings(id); + var settings = profileSettingsService.getProfileSettings(id, profileSettingsId); // then assertThat(settings.getActive()).isFalse(); assertThat(settings.getChildren()).isNull(); } + @Test + void createProfileSettings_shouldThrowNotFound_ifNoSuchProfile() { + // given + var userId = UUID.randomUUID(); + doReturn(userId).when(executionContext).getUserId(); + var id = 1; + var name = "name"; + when(profileRepository.findById(id)).thenReturn(Optional.empty()); + when(exceptionBuilder.notFoundLdResourceByIdException(anyString(), anyString())) + .thenReturn(emptyRequestProcessingException()); + var settings = new CustomProfileSettingsRequestDto(name, false, null); + + // when + var thrown = assertThrows(RequestProcessingException.class, + () -> profileSettingsService.createProfileSettings(id, settings)); + + // then + assertThat(thrown.getClass()).isEqualTo(RequestProcessingException.class); + assertThat(thrown.getMessage()).isEmpty(); + } + @Test void setProfileSettings_shouldThrowNotFound_ifNoSuchProfile() { // given var userId = UUID.randomUUID(); doReturn(userId).when(executionContext).getUserId(); var id = 1; + var name = "name"; when(profileRepository.findById(id)).thenReturn(Optional.empty()); when(exceptionBuilder.notFoundLdResourceByIdException(anyString(), anyString())) .thenReturn(emptyRequestProcessingException()); - var settings = new CustomProfileSettingsRequestDto(false, null); + var profileSettingsId = 5; + var settings = new CustomProfileSettingsRequestDto(name, false, null); // when var thrown = assertThrows(RequestProcessingException.class, - () -> profileSettingsService.setProfileSettings(id, settings)); + () -> profileSettingsService.setProfileSettings(id, profileSettingsId, settings)); // then assertThat(thrown.getClass()).isEqualTo(RequestProcessingException.class); assertThat(thrown.getMessage()).isEmpty(); } + @Test + void deleteProfileSettings_shouldThrowNotFound_ifNoSuchProfile() { + // given + var userId = UUID.randomUUID(); + doReturn(userId).when(executionContext).getUserId(); + var id = 1; + when(profileRepository.findById(id)).thenReturn(Optional.empty()); + when(exceptionBuilder.notFoundLdResourceByIdException(anyString(), anyString())) + .thenReturn(emptyRequestProcessingException()); + var profileSettingsId = 5; + + // when + var thrown = assertThrows(RequestProcessingException.class, + () -> profileSettingsService.deleteProfileSettings(id, profileSettingsId)); + + // then + assertThat(thrown.getClass()).isEqualTo(RequestProcessingException.class); + assertThat(thrown.getMessage()).isEmpty(); + } } diff --git a/src/test/java/org/folio/linked/data/validation/dto/ProfileSettingsNameUniquenessValidatorTest.java b/src/test/java/org/folio/linked/data/validation/dto/ProfileSettingsNameUniquenessValidatorTest.java new file mode 100644 index 000000000..9718ddbff --- /dev/null +++ b/src/test/java/org/folio/linked/data/validation/dto/ProfileSettingsNameUniquenessValidatorTest.java @@ -0,0 +1,85 @@ +package org.folio.linked.data.validation.dto; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import jakarta.validation.ConstraintValidatorContext; +import jakarta.validation.ConstraintValidatorContext.ConstraintViolationBuilder; +import java.util.List; +import org.folio.linked.data.domain.dto.CustomProfileSettingsRequestDto; +import org.folio.linked.data.model.CreateProfileSettingsRequest; +import org.folio.linked.data.service.profile.ProfileSettingsService; +import org.folio.spring.testing.type.UnitTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@UnitTest +@ExtendWith(MockitoExtension.class) +class ProfileSettingsNameUniquenessValidatorTest { + + private static final String SETTINGS_NAME = "My settings"; + + @Mock + private ProfileSettingsService profileSettingsService; + + @Mock + private ConstraintValidatorContext context; + + @Mock + private ConstraintViolationBuilder builder; + + @Mock + private ConstraintViolationBuilder.NodeBuilderCustomizableContext nodeBuilder; + + @InjectMocks + private ProfileSettingsNameUniquenessValidator validator; + + @Test + void shouldReturnTrue_ifNameIsUniqueForProfile() { + when(profileSettingsService.nameExistsForProfile(any(), any())).thenReturn(false); + + assertTrue(validator.isValid(newCreateProfileSettingsRequest(1, SETTINGS_NAME, true), context)); + verifyNoInteractions(context); + } + + @Test + void shouldReturnTrue_ifMissingProfileId() { + assertTrue(validator.isValid(newCreateProfileSettingsRequest(null, SETTINGS_NAME, true), context)); + verifyNoInteractions(context); + } + + @Test + void shouldReturnTrue_ifMissingDto() { + assertTrue(validator.isValid(newCreateProfileSettingsRequest(1, SETTINGS_NAME, false), context)); + verifyNoInteractions(context); + } + + @Test + void shouldReturnFalse_ifNameIsNotUniqueForProfile() { + when(profileSettingsService.nameExistsForProfile(any(), any())).thenReturn(true); + when(context.buildConstraintViolationWithTemplate(any())).thenReturn(builder); + when(builder.addPropertyNode(any())).thenReturn(nodeBuilder); + when(nodeBuilder.addPropertyNode(any())).thenReturn(nodeBuilder); + + assertFalse(validator.isValid(newCreateProfileSettingsRequest(1, SETTINGS_NAME, true), context)); + verify(context, times(1)).disableDefaultConstraintViolation(); + verify(context, times(1)).buildConstraintViolationWithTemplate(any()); + verify(nodeBuilder, times(1)).addConstraintViolation(); + } + + private CreateProfileSettingsRequest newCreateProfileSettingsRequest(Integer profileId, String name, boolean hasDto) { + CustomProfileSettingsRequestDto dto = null; + if (hasDto) { + dto = new CustomProfileSettingsRequestDto(name, true, List.of()); + } + return new CreateProfileSettingsRequest(profileId, dto); + } +} From 8646cd6e7861de5b50d3e1cdfe4c5d265c77a5a8 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 7 Jul 2026 20:35:36 -0700 Subject: [PATCH 2/5] MODLD-1040: Fix path for creating profile settings (#559) --- descriptors/ModuleDescriptor-template.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index a281e9ab6..f15f814a7 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -175,14 +175,14 @@ "permissionsRequired": [ "linked-data.profiles.settings.list.get" ] }, { - "methods": [ "GET" ], - "pathPattern": "/linked-data/profile/{profileId}/settings/{id}", - "permissionsRequired": [ "linked-data.profiles.settings.get" ] + "methods": [ "POST" ], + "pathPattern": "/linked-data/profile/{profileId}/settings", + "permissionsRequired": [ "linked-data.profiles.settings.post" ] }, { - "methods": [ "POST" ], + "methods": [ "GET" ], "pathPattern": "/linked-data/profile/{profileId}/settings/{id}", - "permissionsRequired": [ "linked-data.profiles.settings.post" ] + "permissionsRequired": [ "linked-data.profiles.settings.get" ] }, { "methods": [ "PUT" ], From dcbf0df4fda2e08a30c729986437c5903a92f007 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 14 Jul 2026 01:21:48 -0700 Subject: [PATCH 3/5] MODLD-1040: Add preferred profile settings feature (#564) --- descriptors/ModuleDescriptor-template.json | 30 +++ .../data/controller/ProfileController.java | 26 ++ .../entity/PreferredProfileSettings.java | 30 +++ .../entity/pk/PreferredProfileSettingsPk.java | 22 ++ .../PreferredProfileSettingsRepository.java | 12 + .../PreferredProfileSettingsService.java | 12 + .../PreferredProfileSettingsServiceImpl.java | 68 ++++++ .../scripts/v-3.0.0/metadata/changelog.xml | 1 + ...reate_preferred_profile_settings_table.sql | 15 ++ .../swagger.api/mod-linked-data.yaml | 53 ++++ .../preferredProfileSettingsRequest.json | 15 ++ .../endpoint/PreferredProfileSettingsIT.java | 227 ++++++++++++++++++ 12 files changed, 511 insertions(+) create mode 100644 src/main/java/org/folio/linked/data/model/entity/PreferredProfileSettings.java create mode 100644 src/main/java/org/folio/linked/data/model/entity/pk/PreferredProfileSettingsPk.java create mode 100644 src/main/java/org/folio/linked/data/repo/PreferredProfileSettingsRepository.java create mode 100644 src/main/java/org/folio/linked/data/service/profile/PreferredProfileSettingsService.java create mode 100644 src/main/java/org/folio/linked/data/service/profile/PreferredProfileSettingsServiceImpl.java create mode 100644 src/main/resources/changelog/scripts/v-3.0.0/metadata/tables/create_preferred_profile_settings_table.sql create mode 100644 src/main/resources/swagger.api/schema/profile/preferredProfileSettingsRequest.json create mode 100644 src/test/java/org/folio/linked/data/e2e/endpoint/PreferredProfileSettingsIT.java diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index f15f814a7..53d2ed4c8 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -193,6 +193,21 @@ "methods": [ "DELETE" ], "pathPattern": "/linked-data/profile/{profileId}/settings/{id}", "permissionsRequired": [ "linked-data.profiles.settings.delete" ] + }, + { + "methods": [ "GET" ], + "pathPattern": "/linked-data/profile/{profileId}/preferred", + "permissionsRequired": [ "linked-data.profiles.settings.preferred.get" ] + }, + { + "methods": [ "POST" ], + "pathPattern": "/linked-data/profile/{profileId}/preferred", + "permissionsRequired": [ "linked-data.profiles.settings.preferred.post" ] + }, + { + "methods": [ "DELETE" ], + "pathPattern": "/linked-data/profile/{profileId}/preferred", + "permissionsRequired": [ "linked-data.profiles.settings.preferred.delete" ] } ] }, @@ -444,6 +459,21 @@ "displayName": "Linked Data: Delete profile settings for the profile for the current user", "description": "Delete profile settings for the profile for the current user" }, + { + "permissionName": "linked-data.profiles.settings.preferred.get", + "displayName": "Linked Data: Get preferred workspace profile settings for the profile for the current user", + "description": "Get preferred workspace profile settings for the profile for the current user" + }, + { + "permissionName": "linked-data.profiles.settings.preferred.post", + "displayName": "Linked Data: Create or update preferred profile settings for the profile for the current user", + "description": "Create or updated preferred profile settings for the profile for the current user" + }, + { + "permissionName": "linked-data.profiles.settings.preferred.delete", + "displayName": "Linked Data: Delete preferred profile settings for the profile for the current user", + "description": "Delete preferred profile settings for the profile for the current user" + }, { "permissionName": "linked-data.resources.rdf.get", "displayName": "Linked Data: Export an Instance to RDF JSON-LD", diff --git a/src/main/java/org/folio/linked/data/controller/ProfileController.java b/src/main/java/org/folio/linked/data/controller/ProfileController.java index 7ab645c8b..e5297d779 100644 --- a/src/main/java/org/folio/linked/data/controller/ProfileController.java +++ b/src/main/java/org/folio/linked/data/controller/ProfileController.java @@ -10,10 +10,12 @@ import org.folio.linked.data.domain.dto.CustomProfileSettingsRequestDto; import org.folio.linked.data.domain.dto.CustomProfileSettingsResponseDto; import org.folio.linked.data.domain.dto.PreferredProfileRequest; +import org.folio.linked.data.domain.dto.PreferredProfileSettingsRequest; import org.folio.linked.data.domain.dto.ProfileMetadata; import org.folio.linked.data.model.CreateProfileSettingsRequest; import org.folio.linked.data.rest.resource.ProfileApi; import org.folio.linked.data.service.profile.PreferredProfileService; +import org.folio.linked.data.service.profile.PreferredProfileSettingsService; import org.folio.linked.data.service.profile.ProfileService; import org.folio.linked.data.service.profile.ProfileSettingsService; import org.springframework.http.ResponseEntity; @@ -26,6 +28,7 @@ public class ProfileController implements ProfileApi { private final ProfileService profileService; private final PreferredProfileService preferredProfileService; private final ProfileSettingsService profileSettingsService; + private final PreferredProfileSettingsService preferredProfileSettingsService; private final jakarta.validation.Validator validator; @Override @@ -104,4 +107,27 @@ private void validateCreateProfileSettingsRequest(CreateProfileSettingsRequest r throw new ConstraintViolationException(violations); } } + + @Override + public ResponseEntity> getPreferredProfileSettings(Integer profileId) { + return ResponseEntity.ok(preferredProfileSettingsService.getPreferredProfileSettings(profileId)); + } + + @Override + public ResponseEntity setPreferredProfileSettings( + Integer profileId, + PreferredProfileSettingsRequest preferredProfileSettings + ) { + preferredProfileSettingsService.setPreferredProfileSettings( + profileId, + preferredProfileSettings.getProfileSettingsId() + ); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity deletePreferredProfileSettings(Integer profileId) { + preferredProfileSettingsService.deletePreferredProfileSettings(profileId); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/org/folio/linked/data/model/entity/PreferredProfileSettings.java b/src/main/java/org/folio/linked/data/model/entity/PreferredProfileSettings.java new file mode 100644 index 000000000..1200ed161 --- /dev/null +++ b/src/main/java/org/folio/linked/data/model/entity/PreferredProfileSettings.java @@ -0,0 +1,30 @@ +package org.folio.linked.data.model.entity; + +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.experimental.Accessors; +import org.folio.linked.data.model.entity.pk.PreferredProfileSettingsPk; + +@Data +@Entity +@Table(name = "preferred_profile_settings") +@Accessors(chain = true) +public class PreferredProfileSettings { + + @EmbeddedId + private PreferredProfileSettingsPk id; + + @MapsId("profileId") + @ManyToOne + @JoinColumn(name = "profile_id", nullable = false) + private Profile profile; + + @ManyToOne + @JoinColumn(name = "profile_settings_id", nullable = false) + private ProfileSettings profileSettings; +} diff --git a/src/main/java/org/folio/linked/data/model/entity/pk/PreferredProfileSettingsPk.java b/src/main/java/org/folio/linked/data/model/entity/pk/PreferredProfileSettingsPk.java new file mode 100644 index 000000000..a20af4df7 --- /dev/null +++ b/src/main/java/org/folio/linked/data/model/entity/pk/PreferredProfileSettingsPk.java @@ -0,0 +1,22 @@ +package org.folio.linked.data.model.entity.pk; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.io.Serializable; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Embeddable +@NoArgsConstructor +@AllArgsConstructor +public class PreferredProfileSettingsPk implements Serializable { + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(name = "profile_id", nullable = false) + private Integer profileId; +} diff --git a/src/main/java/org/folio/linked/data/repo/PreferredProfileSettingsRepository.java b/src/main/java/org/folio/linked/data/repo/PreferredProfileSettingsRepository.java new file mode 100644 index 000000000..7bb726b86 --- /dev/null +++ b/src/main/java/org/folio/linked/data/repo/PreferredProfileSettingsRepository.java @@ -0,0 +1,12 @@ +package org.folio.linked.data.repo; + +import java.util.List; +import java.util.UUID; +import org.folio.linked.data.model.entity.PreferredProfileSettings; +import org.folio.linked.data.model.entity.pk.PreferredProfileSettingsPk; +import org.springframework.data.repository.CrudRepository; + +public interface PreferredProfileSettingsRepository + extends CrudRepository { + List findByIdUserIdAndIdProfileId(UUID userId, Integer profileId); +} diff --git a/src/main/java/org/folio/linked/data/service/profile/PreferredProfileSettingsService.java b/src/main/java/org/folio/linked/data/service/profile/PreferredProfileSettingsService.java new file mode 100644 index 000000000..416f3d1a9 --- /dev/null +++ b/src/main/java/org/folio/linked/data/service/profile/PreferredProfileSettingsService.java @@ -0,0 +1,12 @@ +package org.folio.linked.data.service.profile; + +import java.util.List; +import org.folio.linked.data.domain.dto.CustomProfileSettingsMetadata; + +public interface PreferredProfileSettingsService { + void setPreferredProfileSettings(Integer profileId, Integer profileSettingsId); + + List getPreferredProfileSettings(Integer profileId); + + void deletePreferredProfileSettings(Integer profileId); +} diff --git a/src/main/java/org/folio/linked/data/service/profile/PreferredProfileSettingsServiceImpl.java b/src/main/java/org/folio/linked/data/service/profile/PreferredProfileSettingsServiceImpl.java new file mode 100644 index 000000000..4effdeda4 --- /dev/null +++ b/src/main/java/org/folio/linked/data/service/profile/PreferredProfileSettingsServiceImpl.java @@ -0,0 +1,68 @@ +package org.folio.linked.data.service.profile; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.folio.linked.data.domain.dto.CustomProfileSettingsMetadata; +import org.folio.linked.data.exception.RequestProcessingExceptionBuilder; +import org.folio.linked.data.model.entity.PreferredProfileSettings; +import org.folio.linked.data.model.entity.pk.PreferredProfileSettingsPk; +import org.folio.linked.data.repo.PreferredProfileSettingsRepository; +import org.folio.linked.data.repo.ProfileRepository; +import org.folio.linked.data.repo.ProfileSettingsRepository; +import org.folio.spring.FolioExecutionContext; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Log4j2 +@Transactional +public class PreferredProfileSettingsServiceImpl implements PreferredProfileSettingsService { + + private final ProfileRepository profileRepository; + private final ProfileSettingsRepository profileSettingsRepository; + private final PreferredProfileSettingsRepository preferredProfileSettingsRepository; + private final RequestProcessingExceptionBuilder exceptionBuilder; + private final FolioExecutionContext executionContext; + + @Override + public void setPreferredProfileSettings(Integer profileId, Integer profileSettingsId) { + var profile = profileRepository.findById(profileId) + .orElseThrow(() -> exceptionBuilder.notFoundLdResourceByIdException("Profile", String.valueOf(profileId))); + var profileSettings = profileSettingsRepository.findById(profileSettingsId) + .orElseThrow(() -> exceptionBuilder.notFoundLdResourceByIdException( + "ProfileSettings", + String.valueOf(profileSettingsId) + )); + var id = new PreferredProfileSettingsPk(executionContext.getUserId(), profileId); + var preferredProfileSettings = preferredProfileSettingsRepository.findById(id) + .map(pps -> pps.setProfileSettings(profileSettings)) + .orElse(new PreferredProfileSettings().setId(id).setProfile(profile).setProfileSettings(profileSettings)); + preferredProfileSettingsRepository.save(preferredProfileSettings); + } + + @Override + public void deletePreferredProfileSettings(Integer profileId) { + var idToDelete = new PreferredProfileSettingsPk(executionContext.getUserId(), profileId); + preferredProfileSettingsRepository.deleteById(idToDelete); + } + + @Override + public List getPreferredProfileSettings(Integer profileId) { + var userId = executionContext.getUserId(); + var preferredProfileSettings = getPreferredProfileSettings(userId, profileId).stream().toList(); + + return preferredProfileSettings + .stream() + .map(PreferredProfileSettings::getProfileSettings) + .map(p -> new CustomProfileSettingsMetadata(p.getId(), p.getProfile().getId(), p.getName())) + .toList(); + } + + private Optional getPreferredProfileSettings(UUID userId, Integer profileId) { + return preferredProfileSettingsRepository.findById(new PreferredProfileSettingsPk(userId, profileId)); + } +} diff --git a/src/main/resources/changelog/scripts/v-3.0.0/metadata/changelog.xml b/src/main/resources/changelog/scripts/v-3.0.0/metadata/changelog.xml index 3b7413db1..71ba6250d 100644 --- a/src/main/resources/changelog/scripts/v-3.0.0/metadata/changelog.xml +++ b/src/main/resources/changelog/scripts/v-3.0.0/metadata/changelog.xml @@ -4,4 +4,5 @@ xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.4.xsd"> + diff --git a/src/main/resources/changelog/scripts/v-3.0.0/metadata/tables/create_preferred_profile_settings_table.sql b/src/main/resources/changelog/scripts/v-3.0.0/metadata/tables/create_preferred_profile_settings_table.sql new file mode 100644 index 000000000..15a620ae2 --- /dev/null +++ b/src/main/resources/changelog/scripts/v-3.0.0/metadata/tables/create_preferred_profile_settings_table.sql @@ -0,0 +1,15 @@ +--liquibase formatted sql + +--changeset create_preferred_profile_settings_table dbms:postgresql + +create table if not exists preferred_profile_settings +( + user_id uuid not null, + profile_id smallint not null, + profile_settings_id int not null, + primary key (user_id, profile_id), + foreign key (profile_id) references profiles (id) on delete cascade, + foreign key (profile_settings_id) references profile_settings (id) on delete cascade +); + +--rollback drop table preferred_profile_settings; diff --git a/src/main/resources/swagger.api/mod-linked-data.yaml b/src/main/resources/swagger.api/mod-linked-data.yaml index 5240f9d2d..dbb8164e9 100644 --- a/src/main/resources/swagger.api/mod-linked-data.yaml +++ b/src/main/resources/swagger.api/mod-linked-data.yaml @@ -505,6 +505,59 @@ paths: '500': $ref: '#/components/responses/internalServerErrorResponse' + /linked-data/profile/{profileId}/preferred: + get: + operationId: getPreferredProfileSettings + tags: + - profile + description: Get the preferred profile settings for the profile + parameters: + - $ref: '#/components/parameters/profileId' + responses: + '200': + description: Preferred profile settings for the profile and current user + content: + application/json: + schema: + $ref: "schema/profile/customProfileSettingsMetadataArray.json" + '500': + $ref: '#/components/responses/internalServerErrorResponse' + + post: + operationId: setPreferredProfileSettings + tags: + - profile + description: Set the preferred profile settings for the profile + parameters: + - $ref: '#/components/parameters/profileId' + requestBody: + content: + application/json: + schema: + $ref: "schema/profile/preferredProfileSettingsRequest.json" + responses: + '204': + description: Preferred profile settings created or updated successfully + '400': + $ref: '#/components/responses/badRequestResponse' + '500': + $ref: '#/components/responses/internalServerErrorResponse' + + delete: + operationId: deletePreferredProfileSettings + tags: + - profile + description: Delete the preferred profile settings for the profile + parameters: + - $ref: '#/components/parameters/profileId' + responses: + '204': + description: Preferred profile settings deleted successfully + '400': + $ref: '#/components/responses/badRequestResponse' + '500': + $ref: '#/components/responses/internalServerErrorResponse' + /linked-data/resource/{id}/graph: get: operationId: getResourceGraphById diff --git a/src/main/resources/swagger.api/schema/profile/preferredProfileSettingsRequest.json b/src/main/resources/swagger.api/schema/profile/preferredProfileSettingsRequest.json new file mode 100644 index 000000000..913a8c890 --- /dev/null +++ b/src/main/resources/swagger.api/schema/profile/preferredProfileSettingsRequest.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Preferred profile settings request schema", + "type": "object", + "properties": { + "profileSettingsId": { + "type": "integer", + "format": "int16", + "description": "ID of the profile settings" + } + }, + "required": [ + "profileSettingsId" + ] +} diff --git a/src/test/java/org/folio/linked/data/e2e/endpoint/PreferredProfileSettingsIT.java b/src/test/java/org/folio/linked/data/e2e/endpoint/PreferredProfileSettingsIT.java new file mode 100644 index 000000000..3f1ab1848 --- /dev/null +++ b/src/test/java/org/folio/linked/data/e2e/endpoint/PreferredProfileSettingsIT.java @@ -0,0 +1,227 @@ +package org.folio.linked.data.e2e.endpoint; + +import static java.util.UUID.randomUUID; +import static org.folio.linked.data.test.TestUtil.defaultHeadersWithUserId; +import static org.hamcrest.Matchers.equalTo; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import org.folio.linked.data.e2e.base.IntegrationTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.env.Environment; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@IntegrationTest +class PreferredProfileSettingsIT { + private static final String PREFERRED_PROFILE_SETTINGS_URL = "/linked-data/profile/3/preferred"; + private static final String PROFILE_SETTINGS_URL = "/linked-data/profile/3/settings"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private Environment env; + + @Test + void shouldSetPreferredProfileSettings() throws Exception { + // given + var headers = defaultHeadersWithUserId(env, randomUUID().toString()); + headers.setContentType(APPLICATION_JSON); + + var createProfileSettingsRequest = post(PROFILE_SETTINGS_URL) + .headers(headers) + .content(""" + { + "name": "My settings", + "active": true, + "children": [ + { + "id": "Profile:Resource:Property", + "visible": true, + "order": 1 + } + ] + }"""); + var createProfileSettingsResult = mockMvc.perform(createProfileSettingsRequest) + .andExpect(status().isCreated()) + .andReturn(); + var createProfileSettingsResultBody = createProfileSettingsResult.getResponse().getContentAsString(); + var settingsId = JsonPath.read(createProfileSettingsResultBody, "$.id"); + + // when + var postRequest = post(PREFERRED_PROFILE_SETTINGS_URL) + .headers(headers) + .content(""" + { + "profileSettingsId": %d + }""".formatted(settingsId)); + mockMvc.perform(postRequest) + .andExpect(status().isNoContent()); + + + // then + validatePreferredProfileSettings(mockMvc.perform(get(PREFERRED_PROFILE_SETTINGS_URL).headers(headers))); + validatePreferredProfileSettings( + mockMvc.perform(get(PREFERRED_PROFILE_SETTINGS_URL) + .headers(headers)) + ); + } + + @Test + void shouldReturnEmptyPreferredProfileSettings() throws Exception { + // given + var headers = defaultHeadersWithUserId(env, randomUUID().toString()); + + // when + validateEmptyPreferredProfileSettings(mockMvc.perform(get(PREFERRED_PROFILE_SETTINGS_URL).headers(headers))); + } + + @Test + void shouldDeletePreferredProfileSettings() throws Exception { + // given + var headers = defaultHeadersWithUserId(env, randomUUID().toString()); + headers.setContentType(APPLICATION_JSON); + + var createProfileSettingsRequest = post(PROFILE_SETTINGS_URL) + .headers(headers) + .content(""" + { + "name": "My settings", + "active": true, + "children": [ + { + "id": "Profile:Resource:Property", + "visible": true, + "order": 1 + } + ] + }"""); + var createProfileSettingsResult = mockMvc.perform(createProfileSettingsRequest) + .andExpect(status().isCreated()) + .andReturn(); + var createProfileSettingsResultBody = createProfileSettingsResult.getResponse().getContentAsString(); + var settingsId = JsonPath.read(createProfileSettingsResultBody, "$.id"); + + var postRequest = post(PREFERRED_PROFILE_SETTINGS_URL) + .headers(headers) + .content(""" + { + "profileSettingsId": %d + }""".formatted(settingsId)); + mockMvc.perform(postRequest) + .andExpect(status().isNoContent()); + + validatePreferredProfileSettings(mockMvc.perform(get(PREFERRED_PROFILE_SETTINGS_URL).headers(headers))); + + // when + mockMvc.perform(delete(PREFERRED_PROFILE_SETTINGS_URL) + .headers(headers)) + .andExpect(status().isNoContent()); + + // then + validateEmptyPreferredProfileSettings(mockMvc.perform(get(PREFERRED_PROFILE_SETTINGS_URL).headers(headers))); + } + + @Test + void shouldPreferredProfileSettingsDifferPerUser() throws Exception { + // given + var headersUser1 = defaultHeadersWithUserId(env, randomUUID().toString()); + headersUser1.setContentType(APPLICATION_JSON); + var headersUser2 = defaultHeadersWithUserId(env, randomUUID().toString()); + headersUser2.setContentType(APPLICATION_JSON); + + var createProfileSettingsRequestUser1 = post(PROFILE_SETTINGS_URL) + .headers(headersUser1) + .content(""" + { + "name": "settings for user 1", + "active": true, + "children": [ + { + "id": "Profile:Resource:Property", + "visible": true, + "order": 1 + } + ] + }"""); + var createProfileSettingsResultUser1 = mockMvc.perform(createProfileSettingsRequestUser1) + .andExpect(status().isCreated()) + .andReturn(); + var createProfileSettingsResultBodyUser1 = createProfileSettingsResultUser1.getResponse().getContentAsString(); + var settingsIdUser1 = JsonPath.read(createProfileSettingsResultBodyUser1, "$.id"); + + var createProfileSettingsRequestUser2 = post(PROFILE_SETTINGS_URL) + .headers(headersUser2) + .content(""" + { + "name": "user 2 settings", + "active": true, + "children": [ + { + "id": "Profile:Resource:Property", + "visible": true, + "order": 1 + } + ] + }"""); + var createProfileSettingsResultUser2 = mockMvc.perform(createProfileSettingsRequestUser2) + .andExpect(status().isCreated()) + .andReturn(); + var createProfileSettingsResultBodyUser2 = createProfileSettingsResultUser2.getResponse().getContentAsString(); + var settingsIdUser2 = JsonPath.read(createProfileSettingsResultBodyUser2, "$.id"); + + // when: users set their own settings as preferred for profile 3 + mockMvc.perform(post(PREFERRED_PROFILE_SETTINGS_URL) + .headers(headersUser1) + .content(""" + { + "profileSettingsId": %d + }""".formatted(settingsIdUser1))) + .andExpect(status().isNoContent()); + + mockMvc.perform(post(PREFERRED_PROFILE_SETTINGS_URL) + .headers(headersUser2) + .content(""" + { + "profileSettingsId": %d + }""".formatted(settingsIdUser2))) + .andExpect(status().isNoContent()); + + // then: each user sees only their own preferred profile settings + mockMvc.perform(get(PREFERRED_PROFILE_SETTINGS_URL).headers(headersUser1)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.length()", equalTo(1))) + .andExpect(jsonPath("$[0].id", equalTo(settingsIdUser1))) + .andExpect(jsonPath("$[0].name", equalTo("settings for user 1"))); + + mockMvc.perform(get(PREFERRED_PROFILE_SETTINGS_URL).headers(headersUser2)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.length()", equalTo(1))) + .andExpect(jsonPath("$[0].id", equalTo(settingsIdUser2))) + .andExpect(jsonPath("$[0].name", equalTo("user 2 settings"))); + } + + private void validatePreferredProfileSettings(ResultActions result) throws Exception { + result + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$[0].name", equalTo("My settings"))) + .andExpect(jsonPath("$[0].profileId", equalTo(3))) + .andExpect(jsonPath("$.length()", equalTo(1))); + } + + private void validateEmptyPreferredProfileSettings(ResultActions result) throws Exception { + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()", equalTo(0))); + } +} From 7046133ff7cc1d3117fd8c44564b7f0b109dc68c Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Mon, 17 Aug 2026 23:30:01 +0000 Subject: [PATCH 4/5] Update NEWS --- NEWS.md | 3 +++ pom.xml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index 25de8deac..de1999089 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,6 @@ +## 2.0.4 (17-08-2026) +- Update profile settings to allow for multiple settings per profile [MODLD-1040](https://folio-org.atlassian.net/browse/MODLD-1040) + ## 2.0.3 (02-06-2026) - Exclude LIGHT_RESOURCE from reindexing [MODLD-1071](https://folio-org.atlassian.net/browse/MODLD-1071) - Fix Instance loses association with Work when Work profile is changed [MODLD-1072](https://folio-org.atlassian.net/browse/MODLD-1072) diff --git a/pom.xml b/pom.xml index 2e777570c..664c473b6 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ mod-linked-data org.folio mod-linked-data - 2.0.3 + 2.0.4-SNAPSHOT jar From 2a77466eb15f45768f4a001e86a603a16cb00d5d Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Mon, 17 Aug 2026 23:42:03 +0000 Subject: [PATCH 5/5] [maven-release-plugin] prepare release v2.0.4 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 664c473b6..7fffb6f7c 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ mod-linked-data org.folio mod-linked-data - 2.0.4-SNAPSHOT + 2.0.4 jar @@ -686,6 +686,6 @@ https://github.com/folio-org/${project.artifactId} scm:git:git://github.com/folio-org/${project.artifactId}.git scm:git:git@github.com:folio-org/${project.artifactId}.git - v2.0.3 + v2.0.4