feat(tiles): store dashboard and profile tile layouts
Build, Test & Push Auth Service / quality-check (push) Successful in 48s
Build, Test & Push Auth Service / docker (push) Successful in 1m26s

This commit is contained in:
lobo committed 2026-09-28 15:03:16 +02:00
1 parent dd4fcd1546
commit 315e22c220
31 files changed
+464 -165

No files matched your search

@@ -27,13 +27,14 @@ public class PublicProfileController {
public ResponseEntity<?> getPublicProfile(@PathVariable("displayName") String displayName) {
return publicProfileService.findPublicProfile(displayName)
.<ResponseEntity<?>>map(p -> ResponseEntity.ok(new PublicProfileResponse(
p.profile().displayName(), p.profile().memberSince(), p.ticket(), style(p.profile().style()))))
p.profile().displayName(), p.profile().memberSince(), p.ticket(), style(p.profile()))))
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("PROFILE_NOT_FOUND", "No public profile with this name")));
}
private static PublicProfileResponse.Style style(PublicProfile.Style style) {
return new PublicProfileResponse.Style(style.accent(), style.corners(), style.bannerAppid(),
style.backgroundAppid(), style.sections());
private static PublicProfileResponse.Style style(PublicProfile profile) {
PublicProfile.Style style = profile.style();
return new PublicProfileResponse.Style(style.accent(), style.corners(), style.bannerAppid(), style.backgroundAppid(),
profile.tiles().stream().map(tile -> new PublicProfileResponse.Tile(tile.id(), tile.size())).toList());
}
}
@@ -10,7 +10,9 @@ import java.util.List;
public record PublicProfileResponse(String displayName, LocalDate memberSince, String ticket, Style style) {
/**
* @param sections the parts of the profile in their order; a part not listed is hidden
* @param tiles the shown parts of the profile in their order
*/
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List<String> sections) {}
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List<Tile> tiles) {}
public record Tile(String id, String size) {}
}
@@ -3,8 +3,12 @@ package auth_service.modules.profile.model;
import java.time.LocalDate;
import java.util.List;
public record PublicProfile(long userId, String displayName, LocalDate memberSince, List<String> profileStats,
Style style) {
public record PublicProfile(long userId, String displayName, LocalDate memberSince, Style style, List<Tile> tiles) {
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List<String> sections) {}
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid) {}
/**
* @param id a part of the profile, or STATS_ and a stat group of Core
*/
public record Tile(String id, String size) {}
}
@@ -4,6 +4,7 @@ import auth_service.modules.profile.model.PublicProfile;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
@@ -16,24 +17,30 @@ public class PublicProfileRepository {
this.jdbc = jdbc;
}
// Stored as "ID:SIZE", like user.model.Tile.parse, which this module may not use; a bare id (hand-written SQL) is M.
private static PublicProfile.Tile tile(String stored) {
int colon = stored.lastIndexOf(':');
return colon < 0 ? new PublicProfile.Tile(stored, "M")
: new PublicProfile.Tile(stored.substring(0, colon), stored.substring(colon + 1));
}
/**
* Only enabled, not deleted users who switched the public profile on. The name ignores case, like its unique index.
*/
public Optional<PublicProfile> findPublicByDisplayName(String displayName) {
return jdbc.query("""
SELECT u.id, u.display_name, u.registration_date, s.profile_stats, s.profile_accent,
s.profile_corners, s.profile_banner_appid, s.profile_background_appid, s.profile_sections
SELECT u.id, u.display_name, u.registration_date, s.profile_accent, s.profile_corners,
s.profile_banner_appid, s.profile_background_appid, s.profile_tiles
FROM users u JOIN user_settings s ON s.user_id = u.id
WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND u.deleted_at IS NULL AND s.public_profile
""",
(rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"),
rs.getTimestamp("registration_date") != null
? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null,
List.of((String[]) rs.getArray("profile_stats").getArray()),
new PublicProfile.Style(rs.getString("profile_accent"), rs.getString("profile_corners"),
rs.getObject("profile_banner_appid", Long.class),
rs.getObject("profile_background_appid", Long.class),
List.of((String[]) rs.getArray("profile_sections").getArray()))),
rs.getObject("profile_background_appid", Long.class)),
Arrays.stream((String[]) rs.getArray("profile_tiles").getArray()).map(PublicProfileRepository::tile).toList()),
displayName).stream().findFirst();
}
}
@@ -7,7 +7,11 @@ import auth_service.shared.security.Public;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@Public
@@ -16,6 +20,8 @@ public class PublicProfileService {
public record TicketedProfile(PublicProfile profile, String ticket) {}
private static final String STATS_PREFIX = "STATS_";
private final PublicProfileRepository publicProfileRepository;
private final ProfileTicketIssuer profileTicketIssuer;
@@ -24,7 +30,18 @@ public class PublicProfileService {
*/
public Optional<TicketedProfile> findPublicProfile(String displayName) {
return publicProfileRepository.findPublicByDisplayName(displayName)
.map(profile -> new TicketedProfile(profile, profileTicketIssuer.generateProfileTicket(
profile.userId(), profile.profileStats(), profile.style().sections())));
.map(profile -> new TicketedProfile(profile, ticket(profile)));
}
// Core knows sections and stat groups, not tiles: every STATS_ tile is one shared group of the STATS section.
private String ticket(PublicProfile profile) {
Map<Boolean, List<String>> byStats = profile.tiles().stream().map(PublicProfile.Tile::id)
.collect(Collectors.partitioningBy(id -> id.startsWith(STATS_PREFIX)));
List<String> statGroups = byStats.get(true).stream().map(id -> id.substring(STATS_PREFIX.length())).toList();
List<String> sections = new ArrayList<>(byStats.get(false));
if (!statGroups.isEmpty()) {
sections.add("STATS");
}
return profileTicketIssuer.generateProfileTicket(profile.userId(), statGroups, sections);
}
}
@@ -2,7 +2,8 @@ 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.ProfileStatsRequest;
import auth_service.modules.user.api.dto.request.DashboardTilesRequest;
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;
import auth_service.modules.user.api.dto.response.UserProfileResponse;
@@ -47,10 +48,17 @@ public class UserController {
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/settings/profile-stats", version = "1.0")
public ResponseEntity<Void> updateProfileStats(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody ProfileStatsRequest request) {
this.userService.updateProfileStats(principal.getIdAsLong(), request.groups());
@PutMapping(value = "/settings/dashboard-tiles", version = "1.0")
public ResponseEntity<Void> updateDashboardTiles(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody DashboardTilesRequest request) {
this.userService.updateDashboardTiles(principal.getIdAsLong(), ApiMapper.toDomain(request.tiles()));
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/settings/profile-tiles", version = "1.0")
public ResponseEntity<Void> updateProfileTiles(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody ProfileTilesRequest request) {
this.userService.updateProfileTiles(principal.getIdAsLong(), ApiMapper.toDomain(request.tiles()));
return ResponseEntity.noContent().build();
}
@@ -0,0 +1,22 @@
package auth_service.modules.user.api.dto.request;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
/**
* @param tiles the shown tiles in their order; null resets to the default dashboard
*/
public record DashboardTilesRequest(@Size(max = 20) List<@NotNull @Valid TileRequest> tiles) {
public static final String IDS = "GAME_COUNT|MOST_PLAYED|PLAYING|PILE_OF_SHAME|ACTIVITY|ACHIEVEMENTS|STATS_OVERVIEW|"
+ ProfileTilesRequest.STAT_IDS;
@AssertTrue(message = "unknown tile id")
boolean isKnownIds() {
return TileRequest.knownIds(tiles, IDS);
}
}
@@ -1,13 +0,0 @@
package auth_service.modules.user.api.dto.request;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import java.util.List;
/**
* @param groups the stat groups of Core the public profile shows; empty shows none
*/
public record ProfileStatsRequest(
@NotNull List<@NotNull @Pattern(regexp = "PILE|TOP_GAMES|TASTE|HABITS|FINISHED|TREND") String> groups) {
}
@@ -4,15 +4,9 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import java.util.List;
/**
* @param sections the parts of the public profile in their order; a part not listed is hidden
*/
public record ProfileStyleRequest(
@NotNull @Pattern(regexp = AppearanceRequest.ACCENTS) String accent,
@NotNull @Pattern(regexp = AppearanceRequest.CORNERS) String corners,
@Positive Long bannerAppid,
@Positive Long backgroundAppid,
@NotNull List<@NotNull @Pattern(regexp = "SUMMARY|STATUS|PLAYING|FINISHED|STATS") String> sections) {
@Positive Long backgroundAppid) {
}
@@ -0,0 +1,24 @@
package auth_service.modules.user.api.dto.request;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
/**
* @param tiles the shown parts of the public profile in their order; the STATS_ tiles are the shared stat groups
*/
public record ProfileTilesRequest(@NotNull @Size(max = 20) List<@NotNull @Valid TileRequest> tiles) {
/** The stat groups of Core's stats page, as tiles. */
public static final String STAT_IDS = "STATS_PILE|STATS_TREND|STATS_TOP_GAMES|STATS_TASTE|STATS_HABITS|STATS_FINISHED";
public static final String IDS = "SUMMARY|STATUS|PLAYING|FINISHED|" + STAT_IDS;
@AssertTrue(message = "unknown tile id")
boolean isKnownIds() {
return TileRequest.knownIds(tiles, IDS);
}
}
@@ -0,0 +1,19 @@
package auth_service.modules.user.api.dto.request;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import java.util.List;
/**
* The id is checked by the layout request that holds the tile, with {@link #knownIds}.
*/
public record TileRequest(@NotNull String id, @NotNull @Pattern(regexp = "S|M|L") String size) {
/**
* @return true when every id matches; a missing list, tile or id is left to its own constraint
*/
static boolean knownIds(List<TileRequest> tiles, String ids) {
return tiles == null || tiles.stream().allMatch(tile -> tile == null || tile.id() == null || tile.id().matches(ids));
}
}
@@ -1,7 +1,4 @@
package auth_service.modules.user.api.dto.response;
import java.util.List;
public record ProfileStyleResponse(String accent, String corners, Long bannerAppid, Long backgroundAppid,
List<String> sections) {
public record ProfileStyleResponse(String accent, String corners, Long bannerAppid, Long backgroundAppid) {
}
@@ -6,6 +6,6 @@ import java.util.List;
@Builder
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
AppearanceResponse appearance, List<String> profileStats,
ProfileStyleResponse profileStyle) {
AppearanceResponse appearance, ProfileStyleResponse profileStyle,
List<TileResponse> dashboardTiles, List<TileResponse> profileTiles) {
}
@@ -0,0 +1,4 @@
package auth_service.modules.user.api.dto.response;
public record TileResponse(String id, String size) {
}
@@ -3,17 +3,22 @@ package auth_service.modules.user.api.mapper;
import auth_service.modules.user.api.dto.request.AppearanceRequest;
import auth_service.modules.user.api.dto.request.ProfileStyleRequest;
import auth_service.modules.user.api.dto.request.TileRequest;
import auth_service.modules.user.api.dto.request.UserSettingsRequest;
import auth_service.modules.user.api.dto.response.AppearanceResponse;
import auth_service.modules.user.api.dto.response.ProfileStyleResponse;
import auth_service.modules.user.api.dto.response.TileResponse;
import auth_service.modules.user.api.dto.response.SettingsResponse;
import auth_service.modules.user.api.dto.response.UserProfileResponse;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings;
import auth_service.shared.security.Role;
import java.util.List;
public abstract class ApiMapper {
private ApiMapper() {
}
@@ -38,8 +43,10 @@ public abstract class ApiMapper {
.language(user.getSettings().getLanguage())
.publicProfile(user.getSettings().isPublicProfile())
.appearance(toApi(user.getSettings().getAppearance()))
.profileStats(user.getSettings().getProfileStats())
.profileStyle(toApi(user.getSettings().getProfileStyle()))
.dashboardTiles(user.getSettings().getDashboardTiles() == null ? null
: toApi(user.getSettings().getDashboardTiles()))
.profileTiles(toApi(user.getSettings().getProfileTiles()))
.build())
.build();
}
@@ -50,13 +57,22 @@ public abstract class ApiMapper {
}
private static ProfileStyleResponse toApi(ProfileStyle style) {
return new ProfileStyleResponse(style.accent(), style.corners(), style.bannerAppid(), style.backgroundAppid(),
style.sections());
return new ProfileStyleResponse(style.accent(), style.corners(), style.bannerAppid(), style.backgroundAppid());
}
public static ProfileStyle toDomain(ProfileStyleRequest request) {
return new ProfileStyle(request.accent(), request.corners(), request.bannerAppid(), request.backgroundAppid(),
request.sections());
return new ProfileStyle(request.accent(), request.corners(), request.bannerAppid(), request.backgroundAppid());
}
private static List<TileResponse> toApi(List<Tile> tiles) {
return tiles.stream().map(tile -> new TileResponse(tile.id(), tile.size())).toList();
}
/**
* @return null for null, the reset of a layout
*/
public static List<Tile> toDomain(List<TileRequest> tiles) {
return tiles == null ? null : tiles.stream().map(tile -> new Tile(tile.id(), tile.size())).toList();
}
public static Appearance toDomain(AppearanceRequest request) {
@@ -1,26 +1,14 @@
package auth_service.modules.user.model;
import java.util.List;
/**
* The look of the public profile, apart from the {@link Appearance} of the app.
* The look of the public profile, apart from the {@link Appearance} of the app. Its layout is
* {@link UserSettings#getProfileTiles()}.
*
* @param bannerAppid the Steam app whose art is the banner of the profile; null for none
* @param backgroundAppid the Steam app whose art fills the background of the profile; null for none
* @param sections the parts of the profile in their order; a part not listed is hidden
*/
public record ProfileStyle(String accent, String corners, Long bannerAppid, Long backgroundAppid,
List<String> sections) {
public static final List<String> ALL_SECTIONS = List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED", "STATS");
public record ProfileStyle(String accent, String corners, Long bannerAppid, Long backgroundAppid) {
public static final ProfileStyle DEFAULT = new ProfileStyle(Appearance.DEFAULT.accent(), Appearance.DEFAULT.corners(),
null, null, ALL_SECTIONS);
/**
* Keeps each section once, at its first place.
*/
public ProfileStyle {
sections = sections.stream().distinct().toList();
}
null, null);
}
@@ -0,0 +1,41 @@
package auth_service.modules.user.model;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* One tile of a layout (dashboard or public profile). The frontend knows the tiles and their sizes; a layout lists
* the shown tiles in their order.
*
* @param size S, M or L
*/
public record Tile(String id, String size) {
/**
* @param stored "ID:SIZE", as the tile columns hold it
*/
public static Tile parse(String stored) {
int colon = stored.lastIndexOf(':');
return colon < 0 ? new Tile(stored, "M") : new Tile(stored.substring(0, colon), stored.substring(colon + 1));
}
public String stored() {
return id + ":" + size;
}
public static ArrayList<String> storedAll(List<Tile> tiles) {
return tiles.stream().map(Tile::stored).collect(Collectors.toCollection(ArrayList::new));
}
/**
* Keeps each tile once, at its first place.
*/
public static List<Tile> distinct(List<Tile> tiles) {
Map<String, Tile> byId = new LinkedHashMap<>();
tiles.forEach(tile -> byId.putIfAbsent(tile.id(), tile));
return new ArrayList<>(byId.values());
}
}
@@ -15,7 +15,12 @@ public class UserSettings {
private Appearance appearance = Appearance.DEFAULT;
@Builder.Default
private ProfileStyle profileStyle = ProfileStyle.DEFAULT;
/** The stat groups of Core the public profile shows. */
/** Null: never changed, the frontend shows its default dashboard. */
private List<Tile> dashboardTiles;
@Builder.Default
private List<String> profileStats = List.of();
private List<Tile> profileTiles = DEFAULT_PROFILE_TILES;
/** Also the column default of user_settings.profile_tiles. */
public static final List<Tile> DEFAULT_PROFILE_TILES = List.of(new Tile("SUMMARY", "M"), new Tile("STATUS", "M"),
new Tile("PLAYING", "M"), new Tile("FINISHED", "M"));
}
@@ -3,6 +3,7 @@ package auth_service.modules.user.persistence;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.SteamIdHolder;
import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings;
@@ -33,7 +34,9 @@ public interface UserRepository {
@Transactional
void updateAppearance(long userId, Appearance appearance);
void updateProfileStats(long userId, List<String> groups);
void updateDashboardTiles(long userId, List<Tile> tiles);
void updateProfileTiles(long userId, List<Tile> tiles);
void updateProfileStyle(long userId, ProfileStyle style);
@@ -3,6 +3,7 @@ package auth_service.modules.user.persistence;
import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.SteamIdHolder;
import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings;
@@ -16,7 +17,6 @@ import jakarta.transaction.Transactional;
import jakarta.validation.constraints.NotNull;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -100,8 +100,14 @@ public class UserRepositoryImpl implements UserRepository {
@Transactional
@Override
public void updateProfileStats(long userId, List<String> groups) {
settingsOf(userId).setProfileStats(new ArrayList<>(groups));
public void updateDashboardTiles(long userId, List<Tile> tiles) {
settingsOf(userId).setDashboardTiles(tiles == null ? null : Tile.storedAll(tiles));
}
@Transactional
@Override
public void updateProfileTiles(long userId, List<Tile> tiles) {
settingsOf(userId).setProfileTiles(Tile.storedAll(tiles));
}
@Transactional
@@ -112,7 +118,6 @@ public class UserRepositoryImpl implements UserRepository {
settingsEntity.setProfileCorners(style.corners());
settingsEntity.setProfileBannerAppid(style.bannerAppid());
settingsEntity.setProfileBackgroundAppid(style.backgroundAppid());
settingsEntity.setProfileSections(new ArrayList<>(style.sections()));
}
/**
@@ -2,6 +2,8 @@ package auth_service.modules.user.persistence.entities;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.UserSettings;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@@ -11,7 +13,6 @@ import lombok.Setter;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.util.ArrayList;
import java.util.List;
@Entity
@@ -53,10 +54,6 @@ public class UserSettingsEntity {
@Column(name = "show_banner", nullable = false)
private boolean showBanner = Appearance.DEFAULT.showBanner();
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "profile_stats", nullable = false)
private List<String> profileStats = new ArrayList<>();
@Column(name = "profile_accent", nullable = false)
private String profileAccent = ProfileStyle.DEFAULT.accent();
@@ -69,7 +66,12 @@ public class UserSettingsEntity {
@Column(name = "profile_background_appid")
private Long profileBackgroundAppid;
/** "ID:SIZE" entries, see {@link auth_service.modules.user.model.Tile}. */
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "profile_sections", nullable = false)
private List<String> profileSections = new ArrayList<>(ProfileStyle.ALL_SECTIONS);
@Column(name = "dashboard_tiles")
private List<String> dashboardTiles;
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "profile_tiles", nullable = false)
private List<String> profileTiles = Tile.storedAll(UserSettings.DEFAULT_PROFILE_TILES);
}
@@ -2,6 +2,7 @@ package auth_service.modules.user.persistence.mapper;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.entities.UserEntity;
@@ -30,11 +31,12 @@ public abstract class EntityMapper {
.appearance(new Appearance(entity.getSettings().getAccent(), entity.getSettings().getCorners(),
entity.getSettings().isWideLayout(), entity.getSettings().getBackgroundAppid(),
entity.getSettings().getBannerAppid(), entity.getSettings().isShowBanner()))
.profileStats(List.copyOf(entity.getSettings().getProfileStats()))
.profileStyle(new ProfileStyle(entity.getSettings().getProfileAccent(),
entity.getSettings().getProfileCorners(), entity.getSettings().getProfileBannerAppid(),
entity.getSettings().getProfileBackgroundAppid(),
List.copyOf(entity.getSettings().getProfileSections())))
entity.getSettings().getProfileBackgroundAppid()))
.dashboardTiles(entity.getSettings().getDashboardTiles() == null ? null
: entity.getSettings().getDashboardTiles().stream().map(Tile::parse).toList())
.profileTiles(entity.getSettings().getProfileTiles().stream().map(Tile::parse).toList())
.build();
} else {
// Fallback default settings for users without an entry in the database
@@ -4,6 +4,7 @@ import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.SteamIdHolder;
import auth_service.modules.user.model.SteamLinkOutcome;
import auth_service.modules.user.model.User;
@@ -97,14 +98,18 @@ public class UserService {
userRepository.updateAppearance(userId, appearance);
}
/**
* Stores each group once; the request may repeat one.
*/
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
public void updateProfileStats(long userId, List<String> groups) {
userRepository.updateProfileStats(userId, groups.stream().distinct().toList());
public void updateDashboardTiles(long userId, List<Tile> tiles) {
userRepository.updateDashboardTiles(userId, tiles == null ? null : Tile.distinct(tiles));
}
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
public void updateProfileTiles(long userId, List<Tile> tiles) {
userRepository.updateProfileTiles(userId, Tile.distinct(tiles));
}
@RequiresRole(Role.USER)
@@ -0,0 +1,22 @@
-- Tile layouts of the dashboard and the public profile: tile ids in their order as 'ID:SIZE' (size S, M or L); a
-- tile not listed is hidden. dashboard_tiles NULL = never changed, the frontend shows its default dashboard.
ALTER TABLE user_settings
ADD COLUMN dashboard_tiles VARCHAR(32)[],
ADD COLUMN profile_tiles VARCHAR(32)[] NOT NULL DEFAULT '{SUMMARY:M,STATUS:M,PLAYING:M,FINISHED:M}';
-- The profile layout replaces the section list (V16) and the shared stat groups (V12): the sections keep their
-- order, and STATS becomes one STATS_<GROUP> tile per shared group at its place.
UPDATE user_settings s
SET profile_tiles = COALESCE((SELECT array_agg(tile ORDER BY section_no, group_no)
FROM (SELECT sec.no AS section_no, 0 AS group_no, sec.name || ':M' AS tile
FROM unnest(s.profile_sections) WITH ORDINALITY AS sec(name, no)
WHERE sec.name <> 'STATS'
UNION ALL
SELECT sec.no, grp.no, 'STATS_' || grp.name || ':M'
FROM unnest(s.profile_sections) WITH ORDINALITY AS sec(name, no)
CROSS JOIN unnest(s.profile_stats) WITH ORDINALITY AS grp(name, no)
WHERE sec.name = 'STATS') AS tiles), '{}');
ALTER TABLE user_settings
DROP COLUMN profile_sections,
DROP COLUMN profile_stats;
@@ -73,8 +73,9 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
}
@Test
void publicProfile_TicketCarriesTheSharedStatGroups() throws Exception {
jdbc.update("update user_settings set profile_stats = '{PILE,TREND}' where user_id = ?", publicUserId);
void publicProfile_TicketCarriesTheStatTilesAsSharedGroups() throws Exception {
jdbc.update("update user_settings set profile_tiles = '{STATS_PILE:M,PLAYING:S,STATS_TREND:L}' where user_id = ?",
publicUserId);
String body = mockMvc.perform(get("/api/public/profiles/Lobo")).andReturn().getResponse().getContentAsString();
@@ -82,12 +83,13 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
PublicKey key = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicKey)));
Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload();
assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class));
assertEquals(List.of("PLAYING", "STATS"), claims.get("sections", List.class));
}
@Test
void publicProfile_AnswersWithTheOwnersStyle() throws Exception {
jdbc.update("update user_settings set profile_accent = 'red', profile_corners = 'round', profile_banner_appid = 620, "
+ "profile_sections = '{PLAYING,SUMMARY}' where user_id = ?", publicUserId);
+ "profile_tiles = '{PLAYING:S,STATS_PILE:L}' where user_id = ?", publicUserId);
mockMvc.perform(get("/api/public/profiles/Lobo"))
.andExpect(status().isOk())
@@ -95,20 +97,23 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.style.corners").value("round"))
.andExpect(jsonPath("$.style.bannerAppid").value(620))
.andExpect(jsonPath("$.style.backgroundAppid").isEmpty())
.andExpect(jsonPath("$.style.sections[0]").value("PLAYING"))
.andExpect(jsonPath("$.style.sections[1]").value("SUMMARY"));
.andExpect(jsonPath("$.style.tiles[0].id").value("PLAYING"))
.andExpect(jsonPath("$.style.tiles[0].size").value("S"))
.andExpect(jsonPath("$.style.tiles[1].id").value("STATS_PILE"))
.andExpect(jsonPath("$.style.tiles[1].size").value("L"));
}
@Test
void publicProfile_NewRowShowsEverySection() throws Exception {
void publicProfile_NewRowShowsTheDefaultTiles() throws Exception {
mockMvc.perform(get("/api/public/profiles/Lobo"))
.andExpect(jsonPath("$.style.accent").value("violet"))
.andExpect(jsonPath("$.style.sections.length()").value(5));
.andExpect(jsonPath("$.style.tiles.length()").value(4))
.andExpect(jsonPath("$.style.tiles[3].id").value("FINISHED"));
}
@Test
void publicProfile_TicketCarriesTheShownSections() throws Exception {
jdbc.update("update user_settings set profile_sections = '{PLAYING}' where user_id = ?", publicUserId);
void publicProfile_NoStatTiles_TicketSharesNoStats() throws Exception {
jdbc.update("update user_settings set profile_tiles = '{PLAYING:M}' where user_id = ?", publicUserId);
String body = mockMvc.perform(get("/api/public/profiles/Lobo")).andReturn().getResponse().getContentAsString();
@@ -116,6 +121,17 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
PublicKey key = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicKey)));
Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload();
assertEquals(List.of("PLAYING"), claims.get("sections", List.class));
assertEquals(List.of(), claims.get("profileStats", List.class));
}
@Test
void publicProfile_TileWithoutSize_IsMedium() throws Exception {
jdbc.update("update user_settings set profile_tiles = '{PLAYING}' where user_id = ?", publicUserId);
mockMvc.perform(get("/api/public/profiles/Lobo"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.style.tiles[0].id").value("PLAYING"))
.andExpect(jsonPath("$.style.tiles[0].size").value("M"));
}
@Test
@@ -1,6 +1,7 @@
package auth_service.modules.user.api;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.configuration.security.JwtAuthenticationFilter;
import auth_service.configuration.security.JwtService;
import auth_service.shared.security.UserPrincipal;
@@ -350,10 +351,11 @@ class UserControllerTest extends AbstractWebMvcTest {
}
@Test
void getCurrentUserV1_ShowsTheSharedStatGroups() throws Exception {
void getCurrentUserV1_ShowsTheTileLayouts() throws Exception {
String email = "test@example.com";
UserSettings settings = UserSettings.builder().language("en").theme("dark")
.profileStats(List.of("PILE", "TREND")).build();
.dashboardTiles(List.of(new Tile("STATS_TREND", "L")))
.profileTiles(List.of(new Tile("PLAYING", "S"))).build();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
);
@@ -361,33 +363,100 @@ class UserControllerTest extends AbstractWebMvcTest {
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.settings.profileStats[0]").value("PILE"))
.andExpect(jsonPath("$.settings.profileStats[1]").value("TREND"));
.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"));
}
@Test
void updateProfileStats_StoresTheGroups() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
void getCurrentUserV1_NeverChangedDashboard_SendsNullTiles() throws Exception {
String email = "test@example.com";
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(User.builder().email(email)
.settings(UserSettings.builder().language("en").theme("dark").build()).build());
mockMvc.perform(put("/api/users/settings/profile-stats")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"groups\":[\"PILE\",\"TREND\"]}"))
.andExpect(status().isNoContent());
verify(userService).updateProfileStats(1L, List.of("PILE", "TREND"));
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.settings.dashboardTiles").isEmpty())
.andExpect(jsonPath("$.settings.profileTiles.length()").value(4));
}
// OVERVIEW is public anyway, so it is no choice.
@ParameterizedTest
@ValueSource(strings = {"{}", "{\"groups\":[\"OVERVIEW\"]}", "{\"groups\":[\"MONEY\"]}", "{\"groups\":[null]}"})
void updateProfileStats_UnknownOrMissingGroups_Is400(String body) throws Exception {
@Test
void updateDashboardTiles_StoresTheTiles() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/profile-stats")
mockMvc.perform(put("/api/users/settings/dashboard-tiles")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.content("{\"tiles\":[{\"id\":\"STATS_TREND\",\"size\":\"L\"},{\"id\":\"PLAYING\",\"size\":\"S\"}]}"))
.andExpect(status().isNoContent());
verify(userService).updateDashboardTiles(1L, List.of(new Tile("STATS_TREND", "L"), new Tile("PLAYING", "S")));
}
@Test
void updateDashboardTiles_NullTilesResets() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/dashboard-tiles")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"tiles\":null}"))
.andExpect(status().isNoContent());
verify(userService).updateDashboardTiles(1L, null);
}
@Test
void updateProfileTiles_StoresTheTiles() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/profile-tiles")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"tiles\":[{\"id\":\"STATS_PILE\",\"size\":\"M\"}]}"))
.andExpect(status().isNoContent());
verify(userService).updateProfileTiles(1L, List.of(new Tile("STATS_PILE", "M")));
}
// A dashboard tile is no profile tile and the other way round; activity and the game count stay private.
@ParameterizedTest
@ValueSource(strings = {
"dashboard-tiles|{\"tiles\":[{\"id\":\"SUMMARY\",\"size\":\"M\"}]}",
"dashboard-tiles|{\"tiles\":[{\"id\":\"PLAYING\",\"size\":\"XL\"}]}",
"dashboard-tiles|{\"tiles\":[{\"id\":\"PLAYING\"}]}",
"dashboard-tiles|{\"tiles\":[null]}",
"profile-tiles|{\"tiles\":[{\"id\":\"ACTIVITY\",\"size\":\"M\"}]}",
"profile-tiles|{\"tiles\":[{\"id\":\"STATS_OVERVIEW\",\"size\":\"M\"}]}",
"profile-tiles|{\"tiles\":null}",
"profile-tiles|{}"
})
void updateTiles_UnknownIdBadSizeOrMissing_IsRejected(String pathAndBody) throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
String[] parts = pathAndBody.split("\\|", 2);
mockMvc.perform(put("/api/users/settings/" + parts[0])
.contentType(MediaType.APPLICATION_JSON)
.content(parts[1]))
.andExpect(status().isBadRequest());
verifyNoInteractions(userService);
}
@Test
void updateTiles_MoreThanTwentyTiles_IsRejected() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
String tiles = String.join(",", Collections.nCopies(21, "{\"id\":\"PLAYING\",\"size\":\"M\"}"));
mockMvc.perform(put("/api/users/settings/dashboard-tiles")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"tiles\":[" + tiles + "]}"))
.andExpect(status().isBadRequest());
}
@@ -395,7 +464,7 @@ class UserControllerTest extends AbstractWebMvcTest {
void getCurrentUserV1_ShowsTheProfileStyle() throws Exception {
String email = "test@example.com";
UserSettings settings = UserSettings.builder().language("en").theme("dark")
.profileStyle(new ProfileStyle("red", "sharp", 620L, null, List.of("PLAYING"))).build();
.profileStyle(new ProfileStyle("red", "sharp", 620L, null)).build();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
);
@@ -406,8 +475,7 @@ class UserControllerTest extends AbstractWebMvcTest {
.andExpect(jsonPath("$.settings.profileStyle.accent").value("red"))
.andExpect(jsonPath("$.settings.profileStyle.corners").value("sharp"))
.andExpect(jsonPath("$.settings.profileStyle.bannerAppid").value(620))
.andExpect(jsonPath("$.settings.profileStyle.backgroundAppid").isEmpty())
.andExpect(jsonPath("$.settings.profileStyle.sections[0]").value("PLAYING"));
.andExpect(jsonPath("$.settings.profileStyle.backgroundAppid").isEmpty());
}
@Test
@@ -417,23 +485,20 @@ class UserControllerTest extends AbstractWebMvcTest {
mockMvc.perform(put("/api/users/settings/profile-style")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"accent\":\"green\",\"corners\":\"round\",\"bannerAppid\":620,"
+ "\"sections\":[\"STATS\",\"SUMMARY\"]}"))
.content("{\"accent\":\"green\",\"corners\":\"round\",\"bannerAppid\":620}"))
.andExpect(status().isNoContent());
verify(userService).updateProfileStyle(1L, new ProfileStyle("green", "round", 620L, null, List.of("STATS", "SUMMARY")));
verify(userService).updateProfileStyle(1L, new ProfileStyle("green", "round", 620L, null));
}
// Each body differs from a valid one in one field only, so the 400 comes from that field's constraint.
@ParameterizedTest
@ValueSource(strings = {
"{\"accent\":\"hotpink\",\"corners\":\"default\",\"sections\":[]}",
"{\"accent\":\"violet\",\"corners\":\"pointy\",\"sections\":[]}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"bannerAppid\":0,\"sections\":[]}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"backgroundAppid\":-620,\"sections\":[]}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"sections\":[\"NOTES\"]}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"sections\":[null]}",
"{\"accent\":\"violet\",\"corners\":\"default\"}"
"{\"accent\":\"hotpink\",\"corners\":\"default\"}",
"{\"accent\":\"violet\",\"corners\":\"pointy\"}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"bannerAppid\":0}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"backgroundAppid\":-620}",
"{\"corners\":\"default\"}"
})
void updateProfileStyle_UnknownOrMissingValue_IsRejected(String body) throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
@@ -1,17 +0,0 @@
package auth_service.modules.user.model;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ProfileStyleTest extends AbstractUnitTest {
@Test
void keepsEachSectionOnceAtItsFirstPlace() {
assertEquals(List.of("STATS", "PLAYING"),
new ProfileStyle("red", "round", 620L, null, List.of("STATS", "PLAYING", "STATS")).sections());
}
}
@@ -0,0 +1,23 @@
package auth_service.modules.user.model;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class TileTest extends AbstractUnitTest {
@Test
void parse_ReadsTheStoredForm() {
assertEquals(new Tile("STATS_TOP_GAMES", "L"), Tile.parse("STATS_TOP_GAMES:L"));
assertEquals("PLAYING:S", new Tile("PLAYING", "S").stored());
}
@Test
void distinct_KeepsEachTileOnceAtItsFirstPlace() {
assertEquals(List.of(new Tile("PLAYING", "S"), new Tile("SUMMARY", "M")),
Tile.distinct(List.of(new Tile("PLAYING", "S"), new Tile("SUMMARY", "M"), new Tile("PLAYING", "L"))));
}
}
@@ -1,6 +1,7 @@
package auth_service.modules.user.persistence;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.SteamIdHolder;
@@ -293,7 +294,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
assertEquals(ProfileStyle.DEFAULT, userRepository.findByEmail("style@example.com").orElseThrow()
.getSettings().getProfileStyle());
ProfileStyle style = new ProfileStyle("pink", "sharp", 620L, 1086940L, List.of("FINISHED", "SUMMARY"));
ProfileStyle style = new ProfileStyle("pink", "sharp", 620L, 1086940L);
userRepository.updateProfileStyle(userEntity.getId(), style);
entityManager.flush();
entityManager.clear();
@@ -302,26 +303,49 @@ class UserRepositoryTest extends AbstractPersistenceTest {
}
@Test
void updateProfileStats_IsReadBackAndKeptByUpdateSettings() {
void updateTiles_AreReadBackAndKeptByUpdateSettings() {
UserEntity userEntity = entityManager.persist(UserEntity.builder()
.email("stats@example.com")
.email("tiles@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
assertEquals(List.of(), userRepository.findByEmail("stats@example.com").orElseThrow()
.getSettings().getProfileStats());
UserSettings fresh = userRepository.findByEmail("tiles@example.com").orElseThrow().getSettings();
assertNull(fresh.getDashboardTiles());
assertEquals(UserSettings.DEFAULT_PROFILE_TILES, fresh.getProfileTiles());
userRepository.updateProfileStats(userEntity.getId(), List.of("PILE", "TREND"));
List<Tile> dashboard = List.of(new Tile("STATS_TREND", "L"), new Tile("PLAYING", "S"));
List<Tile> profile = List.of(new Tile("STATS_PILE", "M"), new Tile("SUMMARY", "S"));
userRepository.updateDashboardTiles(userEntity.getId(), dashboard);
userRepository.updateProfileTiles(userEntity.getId(), profile);
entityManager.flush();
userRepository.updateSettings(userEntity.getId(), null,
UserSettings.builder().language("de").theme("dark").build());
entityManager.flush();
entityManager.clear();
assertEquals(List.of("PILE", "TREND"), userRepository.findByEmail("stats@example.com").orElseThrow()
.getSettings().getProfileStats());
UserSettings saved = userRepository.findByEmail("tiles@example.com").orElseThrow().getSettings();
assertEquals(dashboard, saved.getDashboardTiles());
assertEquals(profile, saved.getProfileTiles());
}
@Test
void updateDashboardTiles_NullResetsToTheDefault() {
UserEntity userEntity = entityManager.persist(UserEntity.builder()
.email("reset@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
userRepository.updateDashboardTiles(userEntity.getId(), List.of(new Tile("PLAYING", "S")));
entityManager.flush();
userRepository.updateDashboardTiles(userEntity.getId(), null);
entityManager.flush();
entityManager.clear();
assertNull(userRepository.findByEmail("reset@example.com").orElseThrow().getSettings().getDashboardTiles());
}
@Test
@@ -1,5 +1,6 @@
package auth_service.modules.user.service;
import auth_service.modules.user.model.Tile;
import auth_service.shared.security.Role;
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
import auth_service.modules.user.exception.UserNotFoundException;
@@ -64,10 +65,19 @@ class UserServiceTest extends AbstractUnitTest {
}
@Test
void updateProfileStats_StoresEachGroupOnce() {
userService.updateProfileStats(1L, List.of("PILE", "TREND", "PILE"));
void updateTiles_StoreEachTileOnce() {
userService.updateDashboardTiles(1L, List.of(new Tile("PLAYING", "S"), new Tile("PLAYING", "L")));
userService.updateProfileTiles(1L, List.of(new Tile("SUMMARY", "M"), new Tile("SUMMARY", "S")));
verify(userRepository).updateProfileStats(1L, List.of("PILE", "TREND"));
verify(userRepository).updateDashboardTiles(1L, List.of(new Tile("PLAYING", "S")));
verify(userRepository).updateProfileTiles(1L, List.of(new Tile("SUMMARY", "M")));
}
@Test
void updateDashboardTiles_NullStaysNull() {
userService.updateDashboardTiles(1L, null);
verify(userRepository).updateDashboardTiles(1L, null);
}
@Test