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
@@ -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