diff --git a/readme.md b/readme.md index fdb1523..77986ed 100644 --- a/readme.md +++ b/readme.md @@ -78,6 +78,9 @@ To Be Played is split into three repositories: drafts). The dashboard shows the entries published after `user_settings.news_seen_at` once in a dialog; "Got it" sets the stamp to now (`PUT /api/news/seen`). A new user starts at the registration time. `/whats-new` lists all entries. Editing an entry does not make it unseen again. Create, update and delete are rows in `admin_audit`. +- **Welcome page**: `user_settings.welcome_seen_at` is null until the user sees the welcome page; "Let's go" and "Skip" + set it (`PUT /api/users/settings/welcome-seen`). V24 sets it for every existing user, so only users registered after + the deploy get the page. - **Mails** from the platform mailbox: "a new registration waits" to the admin (`APP_MAIL_ADMIN_TO`, with the link to the admin page), and to the user "we got your registration", then "your account is active" or "your registration was denied", later "your account is blocked" and "your account will be deleted from ". The registration sends the browser language (`de` for a German browser, else `en`), which also becomes @@ -123,6 +126,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`). Auth checks only the id shape `[A-Z_]{1,30}`: Core and the Frontend skip ids they do not know, and Core never shares the overview or the activity | access token | +| `PUT` | `/api/users/settings/welcome-seen` (`204`; stamps `user_settings.welcome_seen_at`, sent as `settings.welcomeSeenAt` in `/me`, null until then) | 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}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_`; `204`) | access token | | `GET` | `/api/public/profiles/{displayName}` | 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 f75ef90..ad5b852 100644 --- a/src/main/java/auth_service/modules/user/api/UserController.java +++ b/src/main/java/auth_service/modules/user/api/UserController.java @@ -64,6 +64,12 @@ public class UserController { return ResponseEntity.noContent().build(); } + @PutMapping(value = "/settings/welcome-seen") + public ResponseEntity markWelcomeSeen(@AuthenticationPrincipal UserPrincipal principal) { + this.userService.markWelcomeSeen(principal.getIdAsLong()); + return ResponseEntity.noContent().build(); + } + @PutMapping(value = "/settings/profile-style") public ResponseEntity updateProfileStyle(@AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody ProfileStyleRequest request) { 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 3a8a1dc..e990ac3 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 @@ -2,11 +2,12 @@ package auth_service.modules.user.api.dto.response; import lombok.Builder; +import java.time.Instant; import java.util.List; @Builder public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile, AppearanceResponse appearance, ProfileStyleResponse profileStyle, List dashboardTiles, List profileTiles, - String favoritePlatform) { + String favoritePlatform, Instant welcomeSeenAt) { } 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 e271301..311b401 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 @@ -48,6 +48,7 @@ public abstract class ApiMapper { : toApi(user.getSettings().getDashboardTiles())) .profileTiles(toApi(user.getSettings().getProfileTiles())) .favoritePlatform(user.getSettings().getFavoritePlatform()) + .welcomeSeenAt(user.getSettings().getWelcomeSeenAt()) .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 7dc7c17..44a1c26 100644 --- a/src/main/java/auth_service/modules/user/model/UserSettings.java +++ b/src/main/java/auth_service/modules/user/model/UserSettings.java @@ -3,6 +3,7 @@ package auth_service.modules.user.model; import lombok.Builder; import lombok.Data; +import java.time.Instant; import java.util.List; @Data @@ -20,6 +21,8 @@ public class UserSettings { @Builder.Default private List profileTiles = DEFAULT_PROFILE_TILES; private String favoritePlatform; + /** Null: the welcome page was not seen yet. */ + private Instant welcomeSeenAt; /** 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/entities/UserSettingsEntity.java b/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java index 38f04b2..48c528b 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 @@ -9,6 +9,7 @@ import lombok.Setter; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; +import java.time.Instant; import java.util.List; @Entity @@ -72,4 +73,7 @@ public class UserSettingsEntity { @JdbcTypeCode(SqlTypes.ARRAY) @Column(name = "profile_tiles", nullable = false) private List profileTiles; + + @Column(name = "welcome_seen_at") + private Instant welcomeSeenAt; } 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 b5356ea..4135a8a 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 @@ -35,6 +35,7 @@ public abstract class EntityMapper { : settings.getDashboardTiles().stream().map(Tile::parse).toList()) .profileTiles(settings.getProfileTiles().stream().map(Tile::parse).toList()) .favoritePlatform(settings.getFavoritePlatform()) + .welcomeSeenAt(settings.getWelcomeSeenAt()) .build(); return User.builder() @@ -73,5 +74,6 @@ public abstract class EntityMapper { : Tile.storedAll(settings.getDashboardTiles())); target.setProfileTiles(Tile.storedAll(settings.getProfileTiles())); target.setFavoritePlatform(settings.getFavoritePlatform()); + target.setWelcomeSeenAt(settings.getWelcomeSeenAt()); } } 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 804c9bb..d6e8419 100644 --- a/src/main/java/auth_service/modules/user/service/UserService.java +++ b/src/main/java/auth_service/modules/user/service/UserService.java @@ -22,6 +22,7 @@ import org.springframework.cache.annotation.Caching; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.util.List; import java.util.Optional; @@ -128,6 +129,13 @@ public class UserService { user -> user.getSettings().setFavoritePlatform(platform == null ? null : platform.strip())); } + @RequiresRole(Role.USER) + @Transactional + @CacheEvict(value = "users", key = "#userId") + public void markWelcomeSeen(long userId) { + userRepository.update(userId, user -> user.getSettings().setWelcomeSeenAt(Instant.now())); + } + @RequiresRole(Role.USER) @Transactional @CacheEvict(value = "users", key = "#userId") diff --git a/src/main/resources/db/migration/V24__Welcome_Seen.sql b/src/main/resources/db/migration/V24__Welcome_Seen.sql new file mode 100644 index 0000000..ab92a64 --- /dev/null +++ b/src/main/resources/db/migration/V24__Welcome_Seen.sql @@ -0,0 +1,4 @@ +-- Null: the user has not seen the welcome page (item 20). Every existing user counts as seen, so only users +-- registered after this migration get the page. The registration insert names its columns and leaves it null. +ALTER TABLE user_settings ADD COLUMN welcome_seen_at TIMESTAMPTZ; +UPDATE user_settings SET welcome_seen_at = NOW(); 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 1d05200..528c88e 100644 --- a/src/test/java/auth_service/modules/user/api/UserControllerTest.java +++ b/src/test/java/auth_service/modules/user/api/UserControllerTest.java @@ -33,9 +33,11 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import java.time.Instant; import java.util.Collections; import java.util.List; +import static org.hamcrest.Matchers.nullValue; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; @@ -381,6 +383,40 @@ class UserControllerTest extends AbstractWebMvcTest { .andExpect(jsonPath("$.settings.favoritePlatform").value("PS5")); } + @Test + void getCurrentUser_SendsWelcomeSeenAtNullForANewUser() throws Exception { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(new UserPrincipal("1", null), null, Collections.emptyList()) + ); + when(userService.getUser(1L)).thenReturn(User.builder().email("test@example.com") + .settings(UserSettings.builder().language("en").theme("dark").build()).build()); + mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.settings.welcomeSeenAt").value(nullValue())); + } + + @Test + void getCurrentUser_SendsWelcomeSeenAtAsIsoInstant() throws Exception { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(new UserPrincipal("1", null), null, Collections.emptyList()) + ); + when(userService.getUser(1L)).thenReturn(User.builder().email("test@example.com") + .settings(UserSettings.builder().language("en").theme("dark") + .welcomeSeenAt(Instant.parse("2026-10-01T10:00:00Z")).build()).build()); + mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.settings.welcomeSeenAt").value("2026-10-01T10:00:00Z")); + } + + @Test + void markWelcomeSeen_Returns204() throws Exception { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + new UserPrincipal("1", null), null, Collections.emptyList())); + + mockMvc.perform(put("/api/users/settings/welcome-seen")).andExpect(status().isNoContent()); + + verify(userService).markWelcomeSeen(1L); + } + @Test void getCurrentUser_NeverChangedDashboard_SendsNullTiles() throws Exception { String email = "test@example.com"; 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 802a921..bd38304 100644 --- a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java +++ b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java @@ -14,7 +14,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; import org.springframework.jdbc.core.JdbcTemplate; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Optional; @@ -374,6 +376,28 @@ class UserRepositoryTest extends AbstractPersistenceTest { assertEquals(profile, findByEmail("longtile@example.com").orElseThrow().getSettings().getProfileTiles()); } + @Test + void markWelcomeSeen_IsNullForANewRowAndSurvivesASettingsSave() { + UserEntity userEntity = persisted(UserEntity.builder() + .email("welcome@example.com") + .password("password") + .enabled(true) + .registrationDate(LocalDateTime.now()) + .build()); + assertNull(findByEmail("welcome@example.com").orElseThrow().getSettings().getWelcomeSeenAt()); + + Instant seen = Instant.now().truncatedTo(ChronoUnit.MILLIS); + userRepository.update(userEntity.getId(), user -> user.getSettings().setWelcomeSeenAt(seen)); + entityManager.flush(); + entityManager.clear(); + assertEquals(seen, findByEmail("welcome@example.com").orElseThrow().getSettings().getWelcomeSeenAt()); + + updateSettings(userEntity.getId(), null, UserSettings.builder().language("de").theme("dark").build()); + entityManager.flush(); + entityManager.clear(); + assertEquals(seen, findByEmail("welcome@example.com").orElseThrow().getSettings().getWelcomeSeenAt()); + } + private void updateSettings(long userId, String displayName, UserSettings settings) { userRepository.update(userId, user -> { user.setDisplayName(displayName); diff --git a/src/test/java/auth_service/modules/user/service/UserServiceTest.java b/src/test/java/auth_service/modules/user/service/UserServiceTest.java index aa87049..84e0939 100644 --- a/src/test/java/auth_service/modules/user/service/UserServiceTest.java +++ b/src/test/java/auth_service/modules/user/service/UserServiceTest.java @@ -19,6 +19,8 @@ import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; +import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.function.Consumer; @@ -26,6 +28,7 @@ import java.util.function.Consumer; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; @@ -192,6 +195,14 @@ class UserServiceTest extends AbstractUnitTest { assertEquals("Switch", appliedChanges(1).getFirst().getSettings().getFavoritePlatform()); } + @Test + void markWelcomeSeen_StampsNow() { + userService.markWelcomeSeen(1L); + + Instant stamp = appliedChanges(1).getFirst().getSettings().getWelcomeSeenAt(); + assertTrue(Duration.between(stamp, Instant.now()).abs().getSeconds() < 5); + } + /** * Runs each change user 1 got on a fresh user with default settings. */