diff --git a/readme.md b/readme.md index 70a8196..512e337 100644 --- a/readme.md +++ b/readme.md @@ -95,6 +95,7 @@ To Be Played is split into three repositories: | `PUT` | `/api/users/settings/appearance` | access token | | `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token | | `PUT` | `/api/users/settings/dashboard-tiles` (`{tiles: [{id, size}]}` in order, at most 20; `{tiles: null}` resets; `204`) | access token | +| `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token | | `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`: `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_`; `204`) | access token | | `GET` | `/api/public/profiles/{displayName}` | public | | `GET` | `/api/stats/public` | public | diff --git a/src/main/java/auth_service/modules/user/api/UserController.java b/src/main/java/auth_service/modules/user/api/UserController.java index 6644225..0aeb1f7 100644 --- a/src/main/java/auth_service/modules/user/api/UserController.java +++ b/src/main/java/auth_service/modules/user/api/UserController.java @@ -3,6 +3,7 @@ package auth_service.modules.user.api; import auth_service.shared.security.UserPrincipal; import auth_service.modules.user.api.dto.request.AppearanceRequest; import auth_service.modules.user.api.dto.request.DashboardTilesRequest; +import auth_service.modules.user.api.dto.request.FavoritePlatformRequest; import auth_service.modules.user.api.dto.request.ProfileTilesRequest; import auth_service.modules.user.api.dto.request.ProfileStyleRequest; import auth_service.modules.user.api.dto.request.UserSettingsRequest; @@ -62,6 +63,13 @@ public class UserController { return ResponseEntity.noContent().build(); } + @PutMapping(value = "/settings/favorite-platform", version = "1.0") + public ResponseEntity updateFavoritePlatform(@AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody FavoritePlatformRequest request) { + this.userService.updateFavoritePlatform(principal.getIdAsLong(), request.platform()); + return ResponseEntity.noContent().build(); + } + @PutMapping(value = "/settings/profile-style", version = "1.0") public ResponseEntity updateProfileStyle(@AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody ProfileStyleRequest request) { diff --git a/src/main/java/auth_service/modules/user/api/dto/request/FavoritePlatformRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/FavoritePlatformRequest.java new file mode 100644 index 0000000..e925437 --- /dev/null +++ b/src/main/java/auth_service/modules/user/api/dto/request/FavoritePlatformRequest.java @@ -0,0 +1,10 @@ +package auth_service.modules.user.api.dto.request; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * @param platform free text like Core's game platform: the frontend offers the list; null clears it + */ +public record FavoritePlatformRequest(@Size(max = 32) @Pattern(regexp = ".*\\S.*") String platform) { +} diff --git a/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java index 5779e7e..3a8a1dc 100644 --- a/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java +++ b/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java @@ -7,5 +7,6 @@ import java.util.List; @Builder public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile, AppearanceResponse appearance, ProfileStyleResponse profileStyle, - List dashboardTiles, List profileTiles) { + List dashboardTiles, List profileTiles, + String favoritePlatform) { } diff --git a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java index df388af..e271301 100644 --- a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java +++ b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java @@ -47,6 +47,7 @@ public abstract class ApiMapper { .dashboardTiles(user.getSettings().getDashboardTiles() == null ? null : toApi(user.getSettings().getDashboardTiles())) .profileTiles(toApi(user.getSettings().getProfileTiles())) + .favoritePlatform(user.getSettings().getFavoritePlatform()) .build()) .build(); } diff --git a/src/main/java/auth_service/modules/user/model/UserSettings.java b/src/main/java/auth_service/modules/user/model/UserSettings.java index f7b86f4..7dc7c17 100644 --- a/src/main/java/auth_service/modules/user/model/UserSettings.java +++ b/src/main/java/auth_service/modules/user/model/UserSettings.java @@ -19,6 +19,7 @@ public class UserSettings { private List dashboardTiles; @Builder.Default private List profileTiles = DEFAULT_PROFILE_TILES; + private String favoritePlatform; /** Also the column default of user_settings.profile_tiles. */ public static final List DEFAULT_PROFILE_TILES = List.of(new Tile("SUMMARY", "M"), new Tile("STATUS", "M"), diff --git a/src/main/java/auth_service/modules/user/persistence/UserRepository.java b/src/main/java/auth_service/modules/user/persistence/UserRepository.java index c3c7507..e5e8746 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepository.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepository.java @@ -38,6 +38,8 @@ public interface UserRepository { void updateProfileTiles(long userId, List tiles); + void updateFavoritePlatform(long userId, String platform); + void updateProfileStyle(long userId, ProfileStyle style); Optional findSteamIdHolder(long steamId); diff --git a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java index fc07daf..d813118 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java @@ -110,6 +110,12 @@ public class UserRepositoryImpl implements UserRepository { settingsOf(userId).setProfileTiles(Tile.storedAll(tiles)); } + @Transactional + @Override + public void updateFavoritePlatform(long userId, String platform) { + settingsOf(userId).setFavoritePlatform(platform); + } + @Transactional @Override public void updateProfileStyle(long userId, ProfileStyle style) { diff --git a/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java b/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java index d22611f..8c1e5ef 100644 --- a/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java +++ b/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java @@ -71,6 +71,9 @@ public class UserSettingsEntity { @Column(name = "dashboard_tiles") private List dashboardTiles; + @Column(name = "favorite_platform") + private String favoritePlatform; + @JdbcTypeCode(SqlTypes.ARRAY) @Column(name = "profile_tiles", nullable = false) private List profileTiles = Tile.storedAll(UserSettings.DEFAULT_PROFILE_TILES); diff --git a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java index bce9b44..70a5ab8 100644 --- a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java +++ b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java @@ -37,6 +37,7 @@ public abstract class EntityMapper { .dashboardTiles(entity.getSettings().getDashboardTiles() == null ? null : entity.getSettings().getDashboardTiles().stream().map(Tile::parse).toList()) .profileTiles(entity.getSettings().getProfileTiles().stream().map(Tile::parse).toList()) + .favoritePlatform(entity.getSettings().getFavoritePlatform()) .build(); } else { // Fallback default settings for users without an entry in the database diff --git a/src/main/java/auth_service/modules/user/service/UserService.java b/src/main/java/auth_service/modules/user/service/UserService.java index 071b480..fbde765 100644 --- a/src/main/java/auth_service/modules/user/service/UserService.java +++ b/src/main/java/auth_service/modules/user/service/UserService.java @@ -112,6 +112,13 @@ public class UserService { userRepository.updateProfileTiles(userId, Tile.distinct(tiles)); } + @RequiresRole(Role.USER) + @Transactional + @CacheEvict(value = "usersByEmail", allEntries = true) + public void updateFavoritePlatform(long userId, String platform) { + userRepository.updateFavoritePlatform(userId, platform == null ? null : platform.strip()); + } + @RequiresRole(Role.USER) @Transactional @CacheEvict(value = "usersByEmail", allEntries = true) diff --git a/src/main/resources/db/migration/V18__Favorite_Platform.sql b/src/main/resources/db/migration/V18__Favorite_Platform.sql new file mode 100644 index 0000000..8aa9ab1 --- /dev/null +++ b/src/main/resources/db/migration/V18__Favorite_Platform.sql @@ -0,0 +1,3 @@ +-- The platform preselected when a game is added by hand or imported (Core item 23), an IGDB abbreviation such as +-- 'PS5'. NULL: none. +ALTER TABLE user_settings ADD COLUMN favorite_platform VARCHAR(32); diff --git a/src/test/java/auth_service/modules/user/api/UserControllerTest.java b/src/test/java/auth_service/modules/user/api/UserControllerTest.java index 6d200f4..a7f2ff3 100644 --- a/src/test/java/auth_service/modules/user/api/UserControllerTest.java +++ b/src/test/java/auth_service/modules/user/api/UserControllerTest.java @@ -36,6 +36,7 @@ import java.util.List; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -355,7 +356,7 @@ class UserControllerTest extends AbstractWebMvcTest { String email = "test@example.com"; UserSettings settings = UserSettings.builder().language("en").theme("dark") .dashboardTiles(List.of(new Tile("STATS_TREND", "L"))) - .profileTiles(List.of(new Tile("PLAYING", "S"))).build(); + .profileTiles(List.of(new Tile("PLAYING", "S"))).favoritePlatform("PS5").build(); SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList()) ); @@ -366,7 +367,8 @@ class UserControllerTest extends AbstractWebMvcTest { .andExpect(jsonPath("$.settings.dashboardTiles[0].id").value("STATS_TREND")) .andExpect(jsonPath("$.settings.dashboardTiles[0].size").value("L")) .andExpect(jsonPath("$.settings.profileTiles[0].id").value("PLAYING")) - .andExpect(jsonPath("$.settings.profileTiles[0].size").value("S")); + .andExpect(jsonPath("$.settings.profileTiles[0].size").value("S")) + .andExpect(jsonPath("$.settings.favoritePlatform").value("PS5")); } @Test @@ -410,6 +412,38 @@ class UserControllerTest extends AbstractWebMvcTest { verify(userService).updateDashboardTiles(1L, null); } + @Test + void updateFavoritePlatform_StoresOrClearsIt() throws Exception { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList())); + + mockMvc.perform(put("/api/users/settings/favorite-platform") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"platform\":\"PS5\"}")) + .andExpect(status().isNoContent()); + mockMvc.perform(put("/api/users/settings/favorite-platform") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"platform\":null}")) + .andExpect(status().isNoContent()); + + verify(userService).updateFavoritePlatform(1L, "PS5"); + verify(userService).updateFavoritePlatform(1L, null); + } + + @Test + void updateFavoritePlatform_BlankOrTooLong_Is400() throws Exception { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList())); + + for (String platform : List.of(" ", "x".repeat(33))) { + mockMvc.perform(put("/api/users/settings/favorite-platform") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"platform\":\"" + platform + "\"}")) + .andExpect(status().isBadRequest()); + } + verify(userService, never()).updateFavoritePlatform(anyLong(), any()); + } + @Test void updateProfileTiles_StoresTheTiles() throws Exception { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( diff --git a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java index ea64c67..8f64557 100644 --- a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java +++ b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java @@ -330,6 +330,28 @@ class UserRepositoryTest extends AbstractPersistenceTest { assertEquals(profile, saved.getProfileTiles()); } + @Test + void updateFavoritePlatform_StoresAndClears() { + UserEntity userEntity = entityManager.persist(UserEntity.builder() + .email("favorite@example.com") + .password("password") + .enabled(true) + .registrationDate(LocalDateTime.now()) + .build()); + assertNull(userRepository.findByEmail("favorite@example.com").orElseThrow().getSettings().getFavoritePlatform()); + + userRepository.updateFavoritePlatform(userEntity.getId(), "Switch"); + entityManager.flush(); + entityManager.clear(); + assertEquals("Switch", userRepository.findByEmail("favorite@example.com").orElseThrow().getSettings() + .getFavoritePlatform()); + + userRepository.updateFavoritePlatform(userEntity.getId(), null); + entityManager.flush(); + entityManager.clear(); + assertNull(userRepository.findByEmail("favorite@example.com").orElseThrow().getSettings().getFavoritePlatform()); + } + @Test void updateDashboardTiles_NullResetsToTheDefault() { UserEntity userEntity = entityManager.persist(UserEntity.builder()