Public Access
feat(user): add welcome-seen-at timestamp for welcome page tracking
Null means the welcome page was not seen yet. Existing users are backfilled at migration time so only newly registered users see the page.
This commit is contained in:
1 parent
2b2f45b9af
commit
6b6ac44551
12 files changed
+105
-1
No files matched your search
@@ -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 <date>". 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_<GROUP>`; `204`) | access token |
|
||||
| `GET` | `/api/public/profiles/{displayName}` | public |
|
||||
|
||||
@@ -64,6 +64,12 @@ public class UserController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping(value = "/settings/welcome-seen")
|
||||
public ResponseEntity<Void> markWelcomeSeen(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
this.userService.markWelcomeSeen(principal.getIdAsLong());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping(value = "/settings/profile-style")
|
||||
public ResponseEntity<Void> updateProfileStyle(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody ProfileStyleRequest request) {
|
||||
|
||||
@@ -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<TileResponse> dashboardTiles, List<TileResponse> profileTiles,
|
||||
String favoritePlatform) {
|
||||
String favoritePlatform, Instant welcomeSeenAt) {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<Tile> 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<Tile> DEFAULT_PROFILE_TILES = List.of(new Tile("SUMMARY", "M"), new Tile("STATUS", "M"),
|
||||
|
||||
@@ -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<String> profileTiles;
|
||||
|
||||
@Column(name = "welcome_seen_at")
|
||||
private Instant welcomeSeenAt;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user