diff --git a/readme.md b/readme.md
index bbbc692..5a29f7f 100644
--- a/readme.md
+++ b/readme.md
@@ -29,6 +29,12 @@ To Be Played is split into three repositories:
Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR` or `firstSeenAt` for `LONGEST`).
Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`,
`CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from speedrun.com.
+- **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most
+ 500, never an id in an answer) and gets one inbox note per followed user and batch (`/api/inbox`): a sync with its
+ achievement run, or the hand changes of a quiet 10 minutes. A note counts new games, achievement unlocks, status
+ changes and Steam wishlist adds, and leaves out what the followed user's profile hides (`public_user.sections`).
+ Follows end for good, with a note, when the followed user goes private, and without a note when the user is blocked or
+ deleted. The nightly job at 03:15 deletes notes older than 90 days (`app.inbox.keep-days`); the list shows the newest 50.
- **Backlog order**: every listed `BACKLOG` game (not removed, not hidden) has a row in `backlog_order (user_id,
appid, position)`, kept by a database trigger on `user_game` like the status history: a game entering the backlog
goes to the bottom, a game leaving it loses its row. V34 seeded the first order (priority HIGH, MEDIUM, rest, then
@@ -207,8 +213,13 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public |
| `GET` | `/api/stats/owners?appid=&key=&arg=&page=` (who owns a game in the set behind a community number, `key` default `OWNED`: `listed` display names only, `listedTotal`, `privateCount`, `includesYou`, not cached, `400 BAD_ARG`) | login |
| `GET` | `/api/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public |
+| `GET` | `/api/follows` (`{following: [{id, displayName}], followers: [{id, displayName}]}`, `id` is the follow row; a follower with a private profile has `displayName` null) | login |
+| `PUT`, `DELETE` | `/api/follows/by-name/{displayName}` (follow, unfollow; `204`; PUT: `404 PROFILE_NOT_FOUND` for an unknown, private, blocked or deleted name, `400 FOLLOW_SELF`, `400 FOLLOW_LIMIT`) | login |
+| `DELETE` | `/api/follows/{id}` (unfollow, or remove a follower; only rows of the caller change; `204`) | login |
+| `GET` | `/api/inbox` (`{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login |
+| `PUT` | `/api/inbox/read` (marks all read; `204`) | login |
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
-| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt, listed}]}`, `displayName`, `roleChangedAt` and `listed` optional (`listed`: Auth lists the user by name, kept in `listed_user`, `null` keeps it), `displayName` at most 100 characters, kept in `account_name` for the audit log, `roleChangedAt` kept in `account_role_change`; POST updates, PUT replaces the whole set; `204`) | service token |
+| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}]}`, `displayName`, `roleChangedAt`, `listed` and `publicProfile` optional (`listed`: Auth lists the user by name, kept in `listed_user`, `null` keeps it; `publicProfile`: the profile is public, kept as a row in `public_user` with `profileSections`, `null` keeps row and sections, `false` ends the follows of the user with one `VISIBILITY` note each; `profileSections` `null` = all shown), `displayName` at most 100 characters, kept in `account_name` for the audit log, `roleChangedAt` kept in `account_role_change`; POST updates, PUT replaces the whole set; `204`) | service token |
| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
@@ -238,6 +249,8 @@ flowchart LR
Stats["stats"]
Version["version"]
Account["account"]
+ Follow["follow"]
+ Inbox["inbox"]
Rewards["rewards"]
Steam["steam_adapter"]
Igdb["igdb_adapter"]
@@ -256,8 +269,10 @@ flowchart LR
Speedrun --> SpeedrunApi["speedrun.com API"]
LibRepo --> Db[("PostgreSQL tbp_core")]
Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db
+ Follow -->|"follow, follow_digest, inbox_note, public_user, sync_change, user_game_status_change, account_name"| Db
+ Inbox --> Db
Auth -->|"service token
/internal/accounts"| Account
- Account -->|"per-user rows, deleted_user, blocked_user, listed_user, account_name, user_seen_at"| Db
+ Account -->|"per-user rows, deleted_user, blocked_user, listed_user, public_user, follow, account_name, user_seen_at"| Db
```
| Module | Content |
@@ -265,11 +280,16 @@ flowchart LR
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
| `stats` | Public numbers for the landing page |
| `version` | `GET /api/version`: commit and build time of the running image |
-| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, listing in `listed_user` (users Auth shows by name in owner lists, #26), role changes in `account_role_change`, display names in `account_name`, Steam ID changes stop the nightly sync |
+| `follow` | Follows (`follow`), the digest cursor per followed user (`follow_digest`) and `FollowDigestService`: one note per follower and batch, from `sync_change` and the hand rows of `user_game_status_change` |
+| `inbox` | `inbox_note`: the notes of a user, `GET /api/inbox`, the 90-day purge |
+| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, listing in `listed_user` (users Auth shows by name in owner lists, #26), public profiles and their sections in `public_user` (#27), role changes in `account_role_change`, display names in `account_name`, Steam ID changes stop the nightly sync |
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
+`follow` and `inbox` read `account_name`, `public_user`, `blocked_user`, `deleted_user`, `sync_result`, `sync_change`,
+`user_game` and `user_game_status_change` in SQL; the sync tells `follow` through `shared.event.LibraryActivityEvent`.
+
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
@@ -321,6 +341,27 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
The frontend polls `GET /api/library/sync-status` until the run ends.
+Followers (#27): when the achievement run ends, `AchievementSyncService` publishes `LibraryActivityEvent`, and
+`FollowDigestService` writes one inbox note per follower from the new sync result (`ADDED`, `ACHIEVEMENTS`, `STATUS`
+and `WISHLIST` rows of `sync_change`) and the hand changes since the cursor. Kinds that the profile hides (`public_user.sections`)
+are left out, and the cursor moves anyway. A job every 10 minutes reports the hand changes alone once a user was quiet for 10
+minutes; it never takes a sync result, which may still wait for its achievements.
+
+```mermaid
+sequenceDiagram
+ participant W as LibrarySyncWorker
+ participant A as AchievementSyncService
+ participant F as FollowDigestService
+ participant D as Db
+ W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST)
+ W-->>A: LibrarySyncedEvent
+ A->>D: sync_change (ACHIEVEMENTS)
+ A-->>F: LibraryActivityEvent
+ F->>D: lock follow_digest, count sync + hand changes
+ F->>D: inbox_note per follower (public, not blocked, not deleted)
+ F->>D: move cursor
+```
+
At 01:30 `ImageRepairService` repairs the pictures reported through `POST /api/library/broken-images` (table
`broken_image`, one row per game and kind). Reports only count for Steam games the user owns.
@@ -449,12 +490,14 @@ gets no more syncs and leaves the landing stats and the pile snapshots at once.
flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned ones), then `prune` with all its
user ids, which removes the rows of users Auth does not know (refused for more than
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
+The tombstone also ends the user's follows in both directions without a note and removes the user's `public_user` and
+`follow_digest` rows; the purge removes the user's inbox notes and the notes about the user.
The purge also removes the user's `account_name` and `listed_user` rows and link requests (`game_link_request`).
### Blocked users and Steam ID changes
Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account
-took by a verified link, a display name change, a block, an unblock and a change of the owner-list opt-in (`listed`, #26); its nightly job sends every user (`PUT`). A blocked user is in
+took by a verified link, a display name change, a block, an unblock and a change of the owner-list opt-in (`listed`, #26), of the public profile or of the profile tiles (`publicProfile`, `profileSections`, #27); its nightly job sends every user (`PUT`). A blocked user is in
`blocked_user`: no nightly sync, no new sync run, not in the landing stats. `JwtAuthenticationFilter` refuses a blocked
user's token and a token issued before the user's last role change (`account_role_change`, sent as `roleChangedAt`),
through the cache `loginStates` (30 s, evicted by every state call). An Auth that does not send `roleChangedAt` changes
diff --git a/src/main/java/core_service/modules/account/api/InternalAccountController.java b/src/main/java/core_service/modules/account/api/InternalAccountController.java
index 5b75568..d3adbe4 100644
--- a/src/main/java/core_service/modules/account/api/InternalAccountController.java
+++ b/src/main/java/core_service/modules/account/api/InternalAccountController.java
@@ -65,7 +65,8 @@ public class InternalAccountController {
private static List toStates(AccountStatesRequest request) {
return request.accounts().stream()
.map(account -> new AccountState(account.userId(), account.steamId(), account.displayName(),
- account.blocked(), account.roleChangedAt(), account.listed()))
+ account.blocked(), account.roleChangedAt(), account.listed(), account.publicProfile(),
+ account.profileSections()))
.toList();
}
diff --git a/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java b/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java
index 44e01f8..1b6a6e4 100644
--- a/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java
+++ b/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java
@@ -13,8 +13,12 @@ public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> account
* @param displayName null from an Auth that does not send it yet
* @param roleChangedAt null from an Auth that does not send it yet
* @param listed null from an Auth that does not send it yet
+ * @param publicProfile null from an Auth that does not send it yet
+ * @param profileSections the profile parts the user shows; null = all, or an Auth that does not send them. Unknown names
+ * are dropped by {@code AccountState}
*/
public record AccountStateDto(@NotNull Long userId, Long steamId, @Size(max = 100) String displayName,
- boolean blocked, Instant roleChangedAt, Boolean listed) {
+ boolean blocked, Instant roleChangedAt, Boolean listed,
+ Boolean publicProfile, List profileSections) {
}
}
diff --git a/src/main/java/core_service/modules/account/domain/AccountState.java b/src/main/java/core_service/modules/account/domain/AccountState.java
index 9af3bd5..b225e89 100644
--- a/src/main/java/core_service/modules/account/domain/AccountState.java
+++ b/src/main/java/core_service/modules/account/domain/AccountState.java
@@ -1,6 +1,8 @@
package core_service.modules.account.domain;
import java.time.Instant;
+import java.util.List;
+import java.util.Set;
/**
* What Core copies from Auth for one user.
@@ -9,7 +11,16 @@ import java.time.Instant;
* @param displayName null when Auth did not send one; Core then keeps the stored name
* @param roleChangedAt null from an Auth that does not send it; Core then keeps the stored value
* @param listed null from an Auth that does not send it; Core then keeps the stored listing
+ * @param publicProfile null from an Auth that does not send it; Core then keeps the stored row and its sections
+ * @param profileSections the profile parts the user shows; null = all shown
*/
public record AccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt,
- Boolean listed) {
+ Boolean listed, Boolean publicProfile, List profileSections) {
+
+ // One odd entry must not fail the push of every user, so unknown names are dropped, not refused.
+ private static final Set SECTIONS = Set.of("SUMMARY", "STATUS", "PLAYING", "FINISHED", "STATS");
+
+ public AccountState {
+ profileSections = profileSections == null ? null : profileSections.stream().filter(section -> section != null && SECTIONS.contains(section)).toList();
+ }
}
diff --git a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java
index 4561be3..6913cf1 100644
--- a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java
+++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java
@@ -23,11 +23,11 @@ public class AccountCleanupRepository {
static final List TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped",
- "account_role_change", "blocked_user", "listed_user", "deleted_user");
+ "account_role_change", "blocked_user", "listed_user", "follow", "follow_digest", "inbox_note", "public_user", "deleted_user");
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
private static final List ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
- "game_import", "blocked_user", "listed_user", "account_role_change", "deleted_user", "account_name");
+ "game_import", "blocked_user", "listed_user", "account_role_change", "deleted_user", "account_name", "public_user", "follow", "inbox_note");
private final NamedParameterJdbcTemplate jdbc;
@@ -36,7 +36,7 @@ public class AccountCleanupRepository {
}
/**
- * Also takes the users off the nightly sync list.
+ * Also takes the users off the nightly sync list and ends their follows, both ways, without a note.
*/
public void addDeleted(Collection userIds) {
if (userIds.isEmpty()) {
@@ -45,6 +45,9 @@ public class AccountCleanupRepository {
jdbc.batchUpdate("insert into deleted_user (user_id) values (:id) on conflict do nothing",
userIds.stream().map(id -> new MapSqlParameterSource("id", id)).toArray(MapSqlParameterSource[]::new));
jdbc.update("update user_steam_sync set user_seen_at = null where user_id in (:ids)", ids(userIds));
+ jdbc.update("delete from follow where user_id in (:ids) or followed_user_id in (:ids)", ids(userIds));
+ jdbc.update("delete from follow_digest where user_id in (:ids)", ids(userIds));
+ jdbc.update("delete from public_user where user_id in (:ids)", ids(userIds));
}
/**
@@ -88,6 +91,7 @@ public class AccountCleanupRepository {
for (String table : TABLES) {
counts.put(table, jdbc.update("delete from " + table + " where user_id in (:ids)", ids(userIds)));
}
+ jdbc.update("delete from inbox_note where subject_user_id in (:ids)", ids(userIds));
// The merge copy keeps the users' link requests as JSON, outside game_merge_undo_user.
jdbc.update("""
update game_merge_undo set rows = jsonb_set(rows, '{game_link_request}',
diff --git a/src/main/java/core_service/modules/account/repo/AccountStateRepository.java b/src/main/java/core_service/modules/account/repo/AccountStateRepository.java
index 371ba07..17ae109 100644
--- a/src/main/java/core_service/modules/account/repo/AccountStateRepository.java
+++ b/src/main/java/core_service/modules/account/repo/AccountStateRepository.java
@@ -12,8 +12,9 @@ import java.util.Collection;
import java.util.List;
/**
- * Blocks in blocked_user, listing in listed_user, role changes in account_role_change, the display name in
- * account_name, and what a Steam ID change means for user_steam_sync.
+ * Blocks in blocked_user, listing in listed_user, public profiles in public_user (and the follows that end with them),
+ * role changes in account_role_change, the display name in account_name, and what a Steam ID change means for
+ * user_steam_sync.
* Plain SQL, because the library module owns the entities.
*/
@Repository
@@ -46,6 +47,7 @@ public class AccountStateRepository {
} else if (Boolean.FALSE.equals(state.listed())) {
jdbc.update("delete from listed_user where user_id = :userId", params);
}
+ applyPublicProfile(state, params);
if (state.blocked() || state.steamId() == null) {
jdbc.update("update user_steam_sync set user_seen_at = null where user_id = :userId and user_seen_at is not null",
params);
@@ -58,6 +60,26 @@ public class AccountStateRepository {
}
}
+ // A blocked user ends follows in both directions without a note; a user who goes private tells each follower.
+ private void applyPublicProfile(AccountState state, MapSqlParameterSource params) {
+ if (state.blocked()) {
+ jdbc.update("delete from follow where user_id = :userId or followed_user_id = :userId", params);
+ jdbc.update("delete from public_user where user_id = :userId", params);
+ } else if (Boolean.TRUE.equals(state.publicProfile())) {
+ String[] sections = state.profileSections() == null ? null : state.profileSections().toArray(String[]::new);
+ jdbc.update("insert into public_user (user_id, sections) values (:userId, cast(:sections as varchar[])) "
+ + "on conflict (user_id) do update set sections = excluded.sections",
+ new MapSqlParameterSource(params.getValues()).addValue("sections", sections));
+ } else if (Boolean.FALSE.equals(state.publicProfile())) {
+ jdbc.update("delete from public_user where user_id = :userId", params);
+ jdbc.update("""
+ with ended as (delete from follow where followed_user_id = :userId returning user_id)
+ insert into inbox_note (user_id, kind, subject_user_id, subject_name)
+ select e.user_id, 'VISIBILITY', :userId, (select display_name from account_name where user_id = :userId)
+ from ended e""", params);
+ }
+ }
+
public LoginState findLoginState(long userId) {
return jdbc.queryForObject("""
select exists(select 1 from blocked_user where user_id = :userId) as blocked,
@@ -70,24 +92,29 @@ public class AccountStateRepository {
}
/**
- * After this, exactly the blocked users of the list are blocked, and the listed ones listed unless a state has no
- * listing.
+ * After this, exactly the blocked users of the list are blocked, and the listed and public ones listed and public
+ * unless a state has no such flag.
*/
public void replaceAll(Collection states) {
states.forEach(this::apply);
- List blocked = states.stream().filter(AccountState::blocked).map(AccountState::userId).toList();
- if (blocked.isEmpty()) {
- jdbc.update("delete from blocked_user", new MapSqlParameterSource());
- } else {
- jdbc.update("delete from blocked_user where user_id not in (:ids)", new MapSqlParameterSource("ids", blocked));
- }
+ keepOnly("blocked_user", states.stream().filter(AccountState::blocked).map(AccountState::userId).toList());
if (states.stream().noneMatch(state -> state.listed() == null)) {
- List listed = states.stream().filter(AccountState::listed).map(AccountState::userId).toList();
- if (listed.isEmpty()) {
- jdbc.update("delete from listed_user", new MapSqlParameterSource());
- } else {
- jdbc.update("delete from listed_user where user_id not in (:ids)", new MapSqlParameterSource("ids", listed));
- }
+ keepOnly("listed_user", states.stream().filter(AccountState::listed).map(AccountState::userId).toList());
+ }
+ if (states.stream().noneMatch(state -> state.publicProfile() == null)) {
+ keepOnly("public_user", states.stream().filter(state -> state.publicProfile() && !state.blocked())
+ .map(AccountState::userId).toList());
+ // apply() already told the followers of a user who went private.
+ jdbc.update("delete from follow f where not exists (select 1 from public_user p where p.user_id = f.followed_user_id)",
+ new MapSqlParameterSource());
+ }
+ }
+
+ private void keepOnly(String table, List userIds) {
+ if (userIds.isEmpty()) {
+ jdbc.update("delete from " + table, new MapSqlParameterSource());
+ } else {
+ jdbc.update("delete from " + table + " where user_id not in (:ids)", new MapSqlParameterSource("ids", userIds));
}
}
}
diff --git a/src/main/java/core_service/modules/follow/api/FollowController.java b/src/main/java/core_service/modules/follow/api/FollowController.java
new file mode 100644
index 0000000..caebec7
--- /dev/null
+++ b/src/main/java/core_service/modules/follow/api/FollowController.java
@@ -0,0 +1,68 @@
+package core_service.modules.follow.api;
+
+import core_service.modules.follow.api.dto.FollowsDto;
+import core_service.modules.follow.domain.FollowEntry;
+import core_service.modules.follow.domain.FollowResult;
+import core_service.modules.follow.domain.Follows;
+import core_service.modules.follow.service.FollowService;
+import core_service.shared.api.ErrorResponse;
+import core_service.shared.security.UserPrincipal;
+import jakarta.validation.constraints.Size;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/follows")
+@RequiredArgsConstructor
+public class FollowController {
+
+ private final FollowService followService;
+
+ @GetMapping
+ public ResponseEntity follows(@AuthenticationPrincipal UserPrincipal principal) {
+ Follows follows = followService.follows(principal.getIdAsLong());
+ return ResponseEntity.ok(new FollowsDto(toDto(follows.following()), toDto(follows.followers())));
+ }
+
+ @PutMapping("/by-name/{displayName}")
+ public ResponseEntity> follow(@AuthenticationPrincipal UserPrincipal principal,
+ @PathVariable("displayName") @Size(max = 100) String displayName) {
+ FollowResult result = followService.follow(principal.getIdAsLong(), displayName);
+ return switch (result) {
+ case FOLLOWED -> ResponseEntity.noContent().build();
+ case NOT_FOUND -> ResponseEntity.status(404)
+ .body(new ErrorResponse("PROFILE_NOT_FOUND", "No public profile with this name"));
+ case SELF -> ResponseEntity.badRequest().body(new ErrorResponse("FOLLOW_SELF", "You cannot follow yourself"));
+ case LIMIT -> ResponseEntity.badRequest().body(new ErrorResponse("FOLLOW_LIMIT", "You follow too many people"));
+ };
+ }
+
+ @DeleteMapping("/by-name/{displayName}")
+ public ResponseEntity unfollow(@AuthenticationPrincipal UserPrincipal principal,
+ @PathVariable("displayName") @Size(max = 100) String displayName) {
+ followService.unfollow(principal.getIdAsLong(), displayName);
+ return ResponseEntity.noContent().build();
+ }
+
+ /**
+ * Ends a follow of the caller, or removes a follower of the caller; a row of someone else stays.
+ */
+ @DeleteMapping("/{id}")
+ public ResponseEntity remove(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id) {
+ followService.remove(principal.getIdAsLong(), id);
+ return ResponseEntity.noContent().build();
+ }
+
+ private static List toDto(List entries) {
+ return entries.stream().map(entry -> new FollowsDto.Person(entry.id(), entry.displayName())).toList();
+ }
+}
diff --git a/src/main/java/core_service/modules/follow/api/dto/FollowsDto.java b/src/main/java/core_service/modules/follow/api/dto/FollowsDto.java
new file mode 100644
index 0000000..c160fc6
--- /dev/null
+++ b/src/main/java/core_service/modules/follow/api/dto/FollowsDto.java
@@ -0,0 +1,16 @@
+package core_service.modules.follow.api.dto;
+
+import java.util.List;
+
+/**
+ * Who the caller follows and who follows the caller. Names only: a person has no user id here.
+ */
+public record FollowsDto(List following, List followers) {
+
+ /**
+ * @param id the follow row, to end it; not a user id
+ * @param displayName null for a follower with a private profile
+ */
+ public record Person(long id, String displayName) {
+ }
+}
diff --git a/src/main/java/core_service/modules/follow/domain/Cursor.java b/src/main/java/core_service/modules/follow/domain/Cursor.java
new file mode 100644
index 0000000..703a8c4
--- /dev/null
+++ b/src/main/java/core_service/modules/follow/domain/Cursor.java
@@ -0,0 +1,12 @@
+package core_service.modules.follow.domain;
+
+import java.time.Instant;
+
+/**
+ * How far the followers of a user were told.
+ *
+ * @param syncStartedAt the last reported sync result, null when there was none
+ * @param wishlistChangeId the last reported WISHLIST row of sync_change
+ */
+public record Cursor(Instant syncStartedAt, long statusChangeId, long wishlistChangeId) {
+}
diff --git a/src/main/java/core_service/modules/follow/domain/Digest.java b/src/main/java/core_service/modules/follow/domain/Digest.java
new file mode 100644
index 0000000..4b5edbc
--- /dev/null
+++ b/src/main/java/core_service/modules/follow/domain/Digest.java
@@ -0,0 +1,37 @@
+package core_service.modules.follow.domain;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * What a followed user did in one batch. The section names are those of the profile ticket; this module must not
+ * import the library's enum.
+ */
+public record Digest(int newGames, int achievements, int statusChanges, int wishlistAdds) {
+
+ private static final String SUMMARY = "SUMMARY";
+ private static final List STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED");
+
+ public boolean isEmpty() {
+ return newGames == 0 && achievements == 0 && statusChanges == 0 && wishlistAdds == 0;
+ }
+
+ public Digest plus(Digest other) {
+ return new Digest(newGames + other.newGames, achievements + other.achievements,
+ statusChanges + other.statusChanges, wishlistAdds + other.wishlistAdds);
+ }
+
+ /**
+ * Leaves out what the profile hides. The wishlist is never on the profile, so it has no section.
+ *
+ * @param sections the profile parts the user shows, null = all
+ */
+ public Digest visibleOn(Collection sections) {
+ if (sections == null) {
+ return this;
+ }
+ boolean summary = sections.contains(SUMMARY);
+ boolean status = STATUS_SECTIONS.stream().anyMatch(sections::contains);
+ return new Digest(summary ? newGames : 0, summary ? achievements : 0, status ? statusChanges : 0, wishlistAdds);
+ }
+}
diff --git a/src/main/java/core_service/modules/follow/domain/FollowEntry.java b/src/main/java/core_service/modules/follow/domain/FollowEntry.java
new file mode 100644
index 0000000..85dc4ff
--- /dev/null
+++ b/src/main/java/core_service/modules/follow/domain/FollowEntry.java
@@ -0,0 +1,8 @@
+package core_service.modules.follow.domain;
+
+/**
+ * @param id the follow row, not a user id
+ * @param displayName null for a follower with a private profile
+ */
+public record FollowEntry(long id, String displayName) {
+}
diff --git a/src/main/java/core_service/modules/follow/domain/FollowResult.java b/src/main/java/core_service/modules/follow/domain/FollowResult.java
new file mode 100644
index 0000000..2b30f1b
--- /dev/null
+++ b/src/main/java/core_service/modules/follow/domain/FollowResult.java
@@ -0,0 +1,8 @@
+package core_service.modules.follow.domain;
+
+public enum FollowResult {
+ FOLLOWED,
+ NOT_FOUND,
+ SELF,
+ LIMIT
+}
diff --git a/src/main/java/core_service/modules/follow/domain/Follows.java b/src/main/java/core_service/modules/follow/domain/Follows.java
new file mode 100644
index 0000000..d6ce5c8
--- /dev/null
+++ b/src/main/java/core_service/modules/follow/domain/Follows.java
@@ -0,0 +1,6 @@
+package core_service.modules.follow.domain;
+
+import java.util.List;
+
+public record Follows(List following, List followers) {
+}
diff --git a/src/main/java/core_service/modules/follow/repo/FollowDigestRepository.java b/src/main/java/core_service/modules/follow/repo/FollowDigestRepository.java
new file mode 100644
index 0000000..a69568c
--- /dev/null
+++ b/src/main/java/core_service/modules/follow/repo/FollowDigestRepository.java
@@ -0,0 +1,162 @@
+package core_service.modules.follow.repo;
+
+import core_service.modules.follow.domain.Cursor;
+import core_service.modules.follow.domain.Digest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.sql.Array;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * The cursor per followed user, what happened since it, and the notes. Plain SQL: sync_change, sync_result,
+ * user_game and the trigger log belong to the library module, account tables to the account module.
+ */
+@Repository
+@RequiredArgsConstructor
+public class FollowDigestRepository {
+
+ private final NamedParameterJdbcTemplate jdbc;
+
+ /**
+ * Locks the row until the end of the transaction, so two digests of one user never report a batch twice.
+ */
+ public Optional lockCursor(long userId) {
+ return jdbc.query("select sync_started_at, status_change_id, wishlist_change_id from follow_digest where user_id = :userId for update",
+ user(userId), (rs, i) -> {
+ Timestamp startedAt = rs.getTimestamp("sync_started_at");
+ return new Cursor(startedAt == null ? null : startedAt.toInstant(), rs.getLong("status_change_id"), rs.getLong("wishlist_change_id"));
+ }).stream().findFirst();
+ }
+
+ public Optional findSyncStartedAt(long userId) {
+ return jdbc.query("select sync_started_at from sync_result where user_id = :userId", user(userId),
+ (rs, i) -> rs.getTimestamp("sync_started_at").toInstant()).stream().findFirst();
+ }
+
+ /**
+ * Hidden games never count. Wishlist adds are counted apart, see {@link #countWishlist}.
+ */
+ public Digest countSync(long userId) {
+ return jdbc.queryForObject("""
+ select
+ (select count(distinct c.appid) from sync_change c
+ join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
+ where c.user_id = :userId and c.kind = 'ADDED' and ug.hidden_at is null) as new_games,
+ (select coalesce(sum(c.amount), 0) from sync_change c
+ join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
+ where c.user_id = :userId and c.kind = 'ACHIEVEMENTS' and ug.hidden_at is null) as achievements,
+ (select count(*) from sync_change c
+ join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
+ where c.user_id = :userId and c.kind = 'STATUS' and ug.hidden_at is null) as status_changes""",
+ user(userId),
+ (rs, i) -> new Digest(rs.getInt("new_games"), rs.getInt("achievements"), rs.getInt("status_changes"), 0));
+ }
+
+ public long maxWishlistChangeId(long userId) {
+ Long max = jdbc.queryForObject(
+ "select coalesce(max(id), 0) from sync_change where user_id = :userId and kind = 'WISHLIST'",
+ user(userId), Long.class);
+ return max != null ? max : 0;
+ }
+
+ /**
+ * By row id, not by sync result: an add can join a result that was reported already. Wishlist games are no
+ * library games, so a hidden game does not matter.
+ */
+ public int countWishlist(long userId, long afterId, long upToId) {
+ Integer count = jdbc.queryForObject("select count(*) from sync_change where user_id = :userId "
+ + "and kind = 'WISHLIST' and id > :afterId and id <= :upToId",
+ user(userId).addValue("afterId", afterId).addValue("upToId", upToId), Integer.class);
+ return count != null ? count : 0;
+ }
+
+ public long maxStatusChangeId(long userId) {
+ Long max = jdbc.queryForObject("select coalesce(max(id), 0) from user_game_status_change where user_id = :userId",
+ user(userId), Long.class);
+ return max != null ? max : 0;
+ }
+
+ /**
+ * The changes the user made by hand: no old status = a game added by hand, else a status change.
+ */
+ public Digest countHand(long userId, long afterId, long upToId) {
+ return jdbc.queryForObject("""
+ select count(*) filter (where c.from_status is null) as new_games,
+ count(*) filter (where c.from_status is not null) as status_changes
+ from user_game_status_change c
+ join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
+ where c.user_id = :userId and c.by_user and c.id > :afterId and c.id <= :upToId
+ and ug.hidden_at is null""",
+ user(userId).addValue("afterId", afterId).addValue("upToId", upToId),
+ (rs, i) -> new Digest(rs.getInt("new_games"), 0, rs.getInt("status_changes"), 0));
+ }
+
+ /**
+ * @return null when there is no row or the user did not send sections: nothing is filtered
+ */
+ public List findPublicSections(long userId) {
+ return jdbc.query("select sections from public_user where user_id = :userId", user(userId), rs -> {
+ if (!rs.next()) {
+ return null;
+ }
+ Array sections = rs.getArray("sections");
+ return sections == null ? null : Arrays.asList((String[]) sections.getArray());
+ });
+ }
+
+ /**
+ * One note per follower, only while the followed user is public, not blocked and not deleted and the follower is
+ * not deleted. A name is stored as it is now.
+ *
+ * @return how many notes were written
+ */
+ public int insertDigestNotes(long userId, Digest digest) {
+ return jdbc.update("""
+ insert into inbox_note (user_id, kind, subject_user_id, subject_name, new_games, achievements,
+ status_changes, wishlist_adds)
+ select f.user_id, 'DIGEST', f.followed_user_id,
+ (select display_name from account_name where user_id = f.followed_user_id),
+ :newGames, :achievements, :statusChanges, :wishlistAdds
+ from follow f
+ where f.followed_user_id = :userId
+ and exists (select 1 from public_user p where p.user_id = f.followed_user_id)
+ and not exists (select 1 from blocked_user b where b.user_id = f.followed_user_id)
+ and not exists (select 1 from deleted_user d where d.user_id in (f.followed_user_id, f.user_id))""",
+ user(userId).addValue("newGames", digest.newGames()).addValue("achievements", digest.achievements())
+ .addValue("statusChanges", digest.statusChanges()).addValue("wishlistAdds", digest.wishlistAdds()));
+ }
+
+ public void moveCursor(long userId, Instant syncStartedAt, long statusChangeId, long wishlistChangeId) {
+ jdbc.update("update follow_digest set sync_started_at = :syncStartedAt, status_change_id = :statusChangeId, "
+ + "wishlist_change_id = :wishlistChangeId "
+ + "where user_id = :userId",
+ user(userId).addValue("syncStartedAt", syncStartedAt == null ? null : Timestamp.from(syncStartedAt))
+ .addValue("statusChangeId", statusChangeId).addValue("wishlistChangeId", wishlistChangeId));
+ }
+
+ /**
+ * Followed users with a pending hand change whose newest pending change is older than {@code quietBefore}.
+ */
+ public List findQuietHandChanges(Instant quietBefore) {
+ return jdbc.queryForList("""
+ select d.user_id from follow_digest d
+ where exists (select 1 from follow f where f.followed_user_id = d.user_id)
+ and exists (select 1 from user_game_status_change c
+ where c.user_id = d.user_id and c.by_user and c.id > d.status_change_id)
+ and not exists (select 1 from user_game_status_change c
+ where c.user_id = d.user_id and c.by_user and c.id > d.status_change_id
+ and c.changed_at > :quietBefore)""",
+ new MapSqlParameterSource("quietBefore", Timestamp.from(quietBefore)), Long.class);
+ }
+
+ private static MapSqlParameterSource user(long userId) {
+ return new MapSqlParameterSource("userId", userId);
+ }
+}
diff --git a/src/main/java/core_service/modules/follow/repo/FollowRepository.java b/src/main/java/core_service/modules/follow/repo/FollowRepository.java
new file mode 100644
index 0000000..29378c1
--- /dev/null
+++ b/src/main/java/core_service/modules/follow/repo/FollowRepository.java
@@ -0,0 +1,118 @@
+package core_service.modules.follow.repo;
+
+import core_service.modules.follow.domain.FollowEntry;
+import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Follow rows. Plain SQL: the tables of other modules (account_name, public_user, blocked_user, deleted_user) are
+ * read, not mapped.
+ */
+@Repository
+@RequiredArgsConstructor
+public class FollowRepository {
+
+ static final int LIST_LIMIT = 500;
+
+ private static final RowMapper ENTRY = (rs, i) -> new FollowEntry(rs.getLong("id"),
+ rs.getString("display_name"));
+
+ private final NamedParameterJdbcTemplate jdbc;
+
+ /**
+ * The user behind a name, when the profile is public and the user is neither blocked nor deleted.
+ *
+ * @return empty when no user or more than one user has the name
+ */
+ public Optional findFollowable(String displayName) {
+ List ids = jdbc.queryForList("""
+ select a.user_id from account_name a
+ join public_user p on p.user_id = a.user_id
+ where lower(a.display_name) = lower(:name)
+ and not exists (select 1 from blocked_user b where b.user_id = a.user_id)
+ and not exists (select 1 from deleted_user d where d.user_id = a.user_id)
+ limit 2""", new MapSqlParameterSource("name", displayName), Long.class);
+ return ids.size() == 1 ? Optional.of(ids.get(0)) : Optional.empty();
+ }
+
+ /**
+ * Checks the visibility again in the same statement, so a user who went private in between gets no follower.
+ *
+ * @return true when a row was written
+ */
+ public boolean insert(long userId, long followedUserId) {
+ return jdbc.update("""
+ insert into follow (user_id, followed_user_id)
+ select :userId, :followedUserId
+ where exists (select 1 from public_user where user_id = :followedUserId)
+ on conflict do nothing""", pair(userId, followedUserId)) > 0;
+ }
+
+ public int countFollowing(long userId) {
+ Integer count = jdbc.queryForObject("select count(*) from follow where user_id = :userId",
+ new MapSqlParameterSource("userId", userId), Integer.class);
+ return count != null ? count : 0;
+ }
+
+ /**
+ * With the first follower the cursor starts at "now", so the follower gets no old activity.
+ */
+ public void startDigestIfFirst(long followedUserId) {
+ jdbc.update("""
+ insert into follow_digest (user_id, sync_started_at, status_change_id, wishlist_change_id)
+ select :id, (select sync_started_at from sync_result where user_id = :id),
+ coalesce((select max(id) from user_game_status_change where user_id = :id), 0),
+ coalesce((select max(id) from sync_change where user_id = :id and kind = 'WISHLIST'), 0)
+ where (select count(*) from follow where followed_user_id = :id) = 1
+ on conflict (user_id) do update
+ set sync_started_at = excluded.sync_started_at, status_change_id = excluded.status_change_id,
+ wishlist_change_id = excluded.wishlist_change_id""",
+ new MapSqlParameterSource("id", followedUserId));
+ }
+
+ public void deleteByFollowed(long userId, long followedUserId) {
+ jdbc.update("delete from follow where user_id = :userId and followed_user_id = :followedUserId",
+ pair(userId, followedUserId));
+ }
+
+ /**
+ * Only a row of the caller: the caller follows, or is followed.
+ */
+ public void delete(long userId, long followId) {
+ jdbc.update("delete from follow where id = :id and (user_id = :userId or followed_user_id = :userId)",
+ new MapSqlParameterSource("id", followId).addValue("userId", userId));
+ }
+
+ public List findFollowing(long userId) {
+ return jdbc.query("""
+ select f.id, a.display_name from follow f
+ left join account_name a on a.user_id = f.followed_user_id
+ where f.user_id = :userId
+ order by lower(a.display_name), f.id
+ limit %d""".formatted(LIST_LIMIT), new MapSqlParameterSource("userId", userId), ENTRY);
+ }
+
+ /**
+ * The name of a follower with a private profile is left out.
+ */
+ public List findFollowers(long userId) {
+ return jdbc.query("""
+ select f.id, case when p.user_id is not null then a.display_name end as display_name
+ from follow f
+ left join public_user p on p.user_id = f.user_id
+ left join account_name a on a.user_id = f.user_id
+ where f.followed_user_id = :userId
+ order by lower(case when p.user_id is not null then a.display_name end) nulls last, f.id
+ limit %d""".formatted(LIST_LIMIT), new MapSqlParameterSource("userId", userId), ENTRY);
+ }
+
+ private static MapSqlParameterSource pair(long userId, long followedUserId) {
+ return new MapSqlParameterSource("userId", userId).addValue("followedUserId", followedUserId);
+ }
+}
diff --git a/src/main/java/core_service/modules/follow/service/FollowDigestService.java b/src/main/java/core_service/modules/follow/service/FollowDigestService.java
new file mode 100644
index 0000000..f6f0966
--- /dev/null
+++ b/src/main/java/core_service/modules/follow/service/FollowDigestService.java
@@ -0,0 +1,91 @@
+package core_service.modules.follow.service;
+
+import core_service.modules.follow.domain.Cursor;
+import core_service.modules.follow.domain.Digest;
+import core_service.modules.follow.repo.FollowDigestRepository;
+import core_service.shared.event.LibraryActivityEvent;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * One inbox note per follower and batch. A batch is a sync with its achievement run, plus the hand changes since the
+ * last batch. The cursor moves in every case, so what the profile hides is never reported later.
+ */
+@Service
+@Slf4j
+public class FollowDigestService {
+
+ private final FollowDigestRepository digestRepository;
+ private final TransactionTemplate transaction;
+ private final Duration quiet;
+
+ public FollowDigestService(FollowDigestRepository digestRepository, PlatformTransactionManager transactionManager,
+ @Value("${app.follow.quiet-minutes:10}") long quietMinutes) {
+ this.digestRepository = digestRepository;
+ this.transaction = new TransactionTemplate(transactionManager);
+ this.quiet = Duration.ofMinutes(quietMinutes);
+ }
+
+ @EventListener
+ public void onLibraryActivity(LibraryActivityEvent event) {
+ digestOrLog(event.userId(), true);
+ }
+
+ /**
+ * Only hand changes: it never takes a sync result, which may still wait for its achievements.
+ */
+ @Scheduled(initialDelayString = "${app.follow.digest-ms:600000}", fixedDelayString = "${app.follow.digest-ms:600000}")
+ public void digestHandChanges() {
+ List userIds = digestRepository.findQuietHandChanges(Instant.now().minus(quiet));
+ for (long userId : userIds) {
+ digestOrLog(userId, false);
+ }
+ }
+
+ private void digestOrLog(long userId, boolean withSync) {
+ try {
+ digest(userId, withSync);
+ } catch (RuntimeException e) {
+ log.warn("Follower digest of user {} failed", userId, e);
+ }
+ }
+
+ void digest(long userId, boolean withSync) {
+ transaction.executeWithoutResult(tx -> {
+ Optional locked = digestRepository.lockCursor(userId);
+ if (locked.isEmpty()) {
+ return;
+ }
+ Cursor cursor = locked.get();
+ long upTo = digestRepository.maxStatusChangeId(userId);
+ Digest digest = digestRepository.countHand(userId, cursor.statusChangeId(), upTo);
+ Instant syncStartedAt = cursor.syncStartedAt();
+ long wishlistUpTo = cursor.wishlistChangeId();
+ if (withSync) {
+ Optional current = digestRepository.findSyncStartedAt(userId);
+ if (current.isPresent() && (syncStartedAt == null || current.get().isAfter(syncStartedAt))) {
+ digest = digest.plus(digestRepository.countSync(userId));
+ syncStartedAt = current.get();
+ }
+ wishlistUpTo = digestRepository.maxWishlistChangeId(userId);
+ digest = digest.plus(new Digest(0, 0, 0,
+ digestRepository.countWishlist(userId, cursor.wishlistChangeId(), wishlistUpTo)));
+ }
+ digest = digest.visibleOn(digestRepository.findPublicSections(userId));
+ if (!digest.isEmpty()) {
+ digestRepository.insertDigestNotes(userId, digest);
+ }
+ digestRepository.moveCursor(userId, syncStartedAt, upTo, wishlistUpTo);
+ });
+ }
+}
diff --git a/src/main/java/core_service/modules/follow/service/FollowService.java b/src/main/java/core_service/modules/follow/service/FollowService.java
new file mode 100644
index 0000000..39284fc
--- /dev/null
+++ b/src/main/java/core_service/modules/follow/service/FollowService.java
@@ -0,0 +1,60 @@
+package core_service.modules.follow.service;
+
+import core_service.modules.follow.domain.FollowResult;
+import core_service.modules.follow.domain.Follows;
+import core_service.modules.follow.repo.FollowRepository;
+import core_service.shared.security.RequiresRole;
+import core_service.shared.security.Role;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+@Service
+@Transactional
+@RequiredArgsConstructor
+public class FollowService {
+
+ static final int MAX_FOLLOWING = 500;
+
+ private final FollowRepository followRepository;
+
+ /**
+ * Unknown, private, blocked and deleted names all answer NOT_FOUND, so a name cannot be probed.
+ */
+ @RequiresRole(Role.USER)
+ public FollowResult follow(long userId, String displayName) {
+ Optional followed = followRepository.findFollowable(displayName);
+ if (followed.isEmpty()) {
+ return FollowResult.NOT_FOUND;
+ }
+ if (followed.get() == userId) {
+ return FollowResult.SELF;
+ }
+ if (followRepository.countFollowing(userId) >= MAX_FOLLOWING) {
+ return FollowResult.LIMIT;
+ }
+ if (followRepository.insert(userId, followed.get())) {
+ followRepository.startDigestIfFirst(followed.get());
+ }
+ return FollowResult.FOLLOWED;
+ }
+
+ @RequiresRole(Role.USER)
+ public void unfollow(long userId, String displayName) {
+ followRepository.findFollowable(displayName)
+ .ifPresent(followed -> followRepository.deleteByFollowed(userId, followed));
+ }
+
+ @RequiresRole(Role.USER)
+ public void remove(long userId, long followId) {
+ followRepository.delete(userId, followId);
+ }
+
+ @RequiresRole(Role.USER)
+ @Transactional(readOnly = true)
+ public Follows follows(long userId) {
+ return new Follows(followRepository.findFollowing(userId), followRepository.findFollowers(userId));
+ }
+}
diff --git a/src/main/java/core_service/modules/inbox/api/InboxController.java b/src/main/java/core_service/modules/inbox/api/InboxController.java
new file mode 100644
index 0000000..394aba9
--- /dev/null
+++ b/src/main/java/core_service/modules/inbox/api/InboxController.java
@@ -0,0 +1,36 @@
+package core_service.modules.inbox.api;
+
+import core_service.modules.inbox.api.dto.InboxDto;
+import core_service.modules.inbox.domain.Inbox;
+import core_service.modules.inbox.service.InboxService;
+import core_service.shared.security.UserPrincipal;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/inbox")
+@RequiredArgsConstructor
+public class InboxController {
+
+ private final InboxService inboxService;
+
+ @GetMapping
+ public ResponseEntity inbox(@AuthenticationPrincipal UserPrincipal principal) {
+ Inbox inbox = inboxService.inbox(principal.getIdAsLong());
+ return ResponseEntity.ok(new InboxDto(inbox.notes().stream()
+ .map(note -> new InboxDto.Note(note.id(), note.kind().name(), note.subjectName(), note.newGames(),
+ note.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read()))
+ .toList(), inbox.unread()));
+ }
+
+ @PutMapping("/read")
+ public ResponseEntity markAllRead(@AuthenticationPrincipal UserPrincipal principal) {
+ inboxService.markAllRead(principal.getIdAsLong());
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java b/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java
new file mode 100644
index 0000000..17baa91
--- /dev/null
+++ b/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java
@@ -0,0 +1,18 @@
+package core_service.modules.inbox.api.dto;
+
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * @param unread all unread notes, also those beyond the newest 50 in {@code results}
+ */
+public record InboxDto(List results, int unread) {
+
+ /**
+ * @param kind DIGEST or VISIBILITY
+ * @param displayName the name of the followed user when the note was written; never a user id
+ */
+ public record Note(long id, String kind, String displayName, int newGames, int achievements, int statusChanges,
+ int wishlistAdds, Instant createdAt, boolean read) {
+ }
+}
diff --git a/src/main/java/core_service/modules/inbox/domain/Inbox.java b/src/main/java/core_service/modules/inbox/domain/Inbox.java
new file mode 100644
index 0000000..742cb90
--- /dev/null
+++ b/src/main/java/core_service/modules/inbox/domain/Inbox.java
@@ -0,0 +1,9 @@
+package core_service.modules.inbox.domain;
+
+import java.util.List;
+
+/**
+ * @param unread all unread notes, also those beyond the list
+ */
+public record Inbox(List notes, int unread) {
+}
diff --git a/src/main/java/core_service/modules/inbox/domain/InboxNote.java b/src/main/java/core_service/modules/inbox/domain/InboxNote.java
new file mode 100644
index 0000000..29feeea
--- /dev/null
+++ b/src/main/java/core_service/modules/inbox/domain/InboxNote.java
@@ -0,0 +1,10 @@
+package core_service.modules.inbox.domain;
+
+import java.time.Instant;
+
+/**
+ * @param subjectName the name of the followed user when the note was written
+ */
+public record InboxNote(long id, NoteKind kind, String subjectName, int newGames, int achievements,
+ int statusChanges, int wishlistAdds, Instant createdAt, boolean read) {
+}
diff --git a/src/main/java/core_service/modules/inbox/domain/NoteKind.java b/src/main/java/core_service/modules/inbox/domain/NoteKind.java
new file mode 100644
index 0000000..b9fc73d
--- /dev/null
+++ b/src/main/java/core_service/modules/inbox/domain/NoteKind.java
@@ -0,0 +1,6 @@
+package core_service.modules.inbox.domain;
+
+public enum NoteKind {
+ DIGEST,
+ VISIBILITY
+}
diff --git a/src/main/java/core_service/modules/inbox/repo/InboxRepository.java b/src/main/java/core_service/modules/inbox/repo/InboxRepository.java
new file mode 100644
index 0000000..0fa2146
--- /dev/null
+++ b/src/main/java/core_service/modules/inbox/repo/InboxRepository.java
@@ -0,0 +1,52 @@
+package core_service.modules.inbox.repo;
+
+import core_service.modules.inbox.domain.InboxNote;
+import core_service.modules.inbox.domain.NoteKind;
+import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.List;
+
+@Repository
+@RequiredArgsConstructor
+public class InboxRepository {
+
+ private final NamedParameterJdbcTemplate jdbc;
+
+ public List findNewest(long userId, int limit) {
+ return jdbc.query("""
+ select id, kind, subject_name, new_games, achievements, status_changes, wishlist_adds, created_at, read_at
+ from inbox_note where user_id = :userId
+ order by created_at desc, id desc
+ limit :limit""", new MapSqlParameterSource("userId", userId).addValue("limit", limit),
+ (rs, i) -> new InboxNote(rs.getLong("id"), NoteKind.valueOf(rs.getString("kind")),
+ rs.getString("subject_name"), rs.getInt("new_games"), rs.getInt("achievements"),
+ rs.getInt("status_changes"), rs.getInt("wishlist_adds"),
+ rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("read_at") != null));
+ }
+
+ public int countUnread(long userId) {
+ Integer count = jdbc.queryForObject("select count(*) from inbox_note where user_id = :userId and read_at is null",
+ new MapSqlParameterSource("userId", userId), Integer.class);
+ return count != null ? count : 0;
+ }
+
+ public void markAllRead(long userId) {
+ jdbc.update("update inbox_note set read_at = now() where user_id = :userId and read_at is null",
+ new MapSqlParameterSource("userId", userId));
+ }
+
+ /**
+ * Read and unread notes alike.
+ *
+ * @return how many notes were deleted
+ */
+ public int deleteBefore(Instant cutoff) {
+ return jdbc.update("delete from inbox_note where created_at < :cutoff",
+ new MapSqlParameterSource("cutoff", Timestamp.from(cutoff)));
+ }
+}
diff --git a/src/main/java/core_service/modules/inbox/service/InboxService.java b/src/main/java/core_service/modules/inbox/service/InboxService.java
new file mode 100644
index 0000000..fe01123
--- /dev/null
+++ b/src/main/java/core_service/modules/inbox/service/InboxService.java
@@ -0,0 +1,48 @@
+package core_service.modules.inbox.service;
+
+import core_service.modules.inbox.domain.Inbox;
+import core_service.modules.inbox.repo.InboxRepository;
+import core_service.shared.security.RequiresRole;
+import core_service.shared.security.Role;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Duration;
+import java.time.Instant;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class InboxService {
+
+ static final int LIST_SIZE = 50;
+
+ private final InboxRepository inboxRepository;
+
+ @Value("${app.inbox.keep-days:90}")
+ private long keepDays;
+
+ @RequiresRole(Role.USER)
+ @Transactional(readOnly = true)
+ public Inbox inbox(long userId) {
+ return new Inbox(inboxRepository.findNewest(userId, LIST_SIZE), inboxRepository.countUnread(userId));
+ }
+
+ @RequiresRole(Role.USER)
+ @Transactional
+ public void markAllRead(long userId) {
+ inboxRepository.markAllRead(userId);
+ }
+
+ @Scheduled(cron = "${app.inbox.purge-cron:0 15 3 * * *}", zone = "${app.library.zone:Europe/Berlin}")
+ public void purgeOld() {
+ int deleted = inboxRepository.deleteBefore(Instant.now().minus(Duration.ofDays(keepDays)));
+ if (deleted > 0) {
+ log.info("Deleted {} inbox notes older than {} days", deleted, keepDays);
+ }
+ }
+}
diff --git a/src/main/java/core_service/modules/library/domain/SyncChange.java b/src/main/java/core_service/modules/library/domain/SyncChange.java
index 540dad9..0f2bcd6 100644
--- a/src/main/java/core_service/modules/library/domain/SyncChange.java
+++ b/src/main/java/core_service/modules/library/domain/SyncChange.java
@@ -26,4 +26,8 @@ public record SyncChange(SyncChangeKind kind, long appid, Integer amount, GameSt
public static SyncChange status(long appid, GameStatus from, GameStatus to) {
return new SyncChange(SyncChangeKind.STATUS, appid, null, from, to);
}
+
+ public static SyncChange wishlist(long appid) {
+ return new SyncChange(SyncChangeKind.WISHLIST, appid, null, null, null);
+ }
}
diff --git a/src/main/java/core_service/modules/library/domain/SyncChangeKind.java b/src/main/java/core_service/modules/library/domain/SyncChangeKind.java
index 4102da3..a02cc54 100644
--- a/src/main/java/core_service/modules/library/domain/SyncChangeKind.java
+++ b/src/main/java/core_service/modules/library/domain/SyncChangeKind.java
@@ -5,5 +5,7 @@ public enum SyncChangeKind {
REMOVED,
PLAYTIME,
ACHIEVEMENTS,
- STATUS
+ STATUS,
+ // Only for followers' digests: never shown in the sync banner.
+ WISHLIST
}
diff --git a/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java b/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java
index 66b45d6..c74c6fb 100644
--- a/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java
+++ b/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java
@@ -34,7 +34,7 @@ public class SyncChangeRepository {
*/
public boolean replace(long userId, Instant syncStartedAt, SyncTrigger startedBy, int importedGames,
List changes) {
- boolean empty = changes.isEmpty() && importedGames == 0;
+ boolean empty = importedGames == 0 && changes.stream().allMatch(change -> change.kind() == SyncChangeKind.WISHLIST);
int written = jdbc.update("""
insert into sync_result (user_id, sync_started_at, started_by, imported_games, seen_at)
select cast(:userId as bigint), :startedAt, cast(:startedBy as varchar), cast(:importedGames as integer), null
@@ -43,13 +43,14 @@ public class SyncChangeRepository {
set sync_started_at = excluded.sync_started_at, started_by = excluded.started_by,
imported_games = excluded.imported_games, seen_at = null
where not (:empty and sync_result.seen_at is null and (sync_result.imported_games > 0
- or exists (select 1 from sync_change where user_id = sync_result.user_id)))
+ or exists (select 1 from sync_change where user_id = sync_result.user_id and kind <> 'WISHLIST')))
""", new MapSqlParameterSource("userId", userId)
.addValue("startedAt", Timestamp.from(syncStartedAt))
.addValue("startedBy", startedBy.name())
.addValue("importedGames", importedGames)
.addValue("empty", empty));
if (written == 0) {
+ keepWishlistAdds(userId, syncStartedAt, changes);
return false;
}
jdbc.update("delete from sync_change where user_id = :userId", new MapSqlParameterSource("userId", userId));
@@ -60,6 +61,22 @@ public class SyncChangeRepository {
return true;
}
+ // The kept result stays as it is, but a wishlist add must still reach the followers: it joins the result's rows.
+ private void keepWishlistAdds(long userId, Instant syncStartedAt, List changes) {
+ List adds = changes.stream().filter(change -> change.kind() == SyncChangeKind.WISHLIST).toList();
+ if (adds.isEmpty()) {
+ return;
+ }
+ jdbc.batchUpdate("""
+ insert into sync_change (user_id, kind, appid)
+ select :userId, 'WISHLIST', :appid
+ where exists (select 1 from user_steam_sync where user_id = :userId and sync_started_at = :startedAt)
+ and exists (select 1 from sync_result where user_id = :userId)
+ """, adds.stream().map(change -> new MapSqlParameterSource("userId", userId)
+ .addValue("appid", change.appid()).addValue("startedAt", Timestamp.from(syncStartedAt)))
+ .toArray(SqlParameterSource[]::new));
+ }
+
/**
* The moves of the sync that the trigger log wrote in this transaction. {@code now()} is the start of the
* transaction, like in {@link UserSteamSyncRepository#markHistoryBaseline}: call it in the transaction that made
@@ -114,7 +131,7 @@ public class SyncChangeRepository {
from sync_change c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
left join steam_game sg on sg.appid = c.appid
- where c.user_id = :userId and ug.hidden_at is null
+ where c.user_id = :userId and ug.hidden_at is null and c.kind <> 'WISHLIST'
group by c.kind, c.appid, c.from_status, c.to_status, sg.name, sg.image_url
order by c.kind, amount desc nulls last, sg.name
""", params, (rs, i) -> new SyncChanges.Entry(
diff --git a/src/main/java/core_service/modules/library/service/AchievementSyncService.java b/src/main/java/core_service/modules/library/service/AchievementSyncService.java
index 6fd0583..f97a756 100644
--- a/src/main/java/core_service/modules/library/service/AchievementSyncService.java
+++ b/src/main/java/core_service/modules/library/service/AchievementSyncService.java
@@ -18,8 +18,10 @@ import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievemen
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.background.BackgroundPools;
+import core_service.shared.event.LibraryActivityEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
@@ -63,6 +65,7 @@ public class AchievementSyncService {
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
private final SteamApiService steamApiService;
private final SyncChangeRepository syncChangeRepository;
+ private final ApplicationEventPublisher eventPublisher;
private final TransactionTemplate transactionTemplate;
private final Duration schemaTtl;
private final long pauseNanos;
@@ -80,6 +83,7 @@ public class AchievementSyncService {
UserGameAchievementMetaRepository userAchievementMetaRepository,
SteamApiService steamApiService,
SyncChangeRepository syncChangeRepository,
+ ApplicationEventPublisher eventPublisher,
PlatformTransactionManager transactionManager,
@Value("${app.library.schema-ttl-days:30}") long schemaTtlDays,
// Half of the Steam rate limit, so requests of users still get through.
@@ -91,6 +95,7 @@ public class AchievementSyncService {
this.userAchievementMetaRepository = userAchievementMetaRepository;
this.steamApiService = steamApiService;
this.syncChangeRepository = syncChangeRepository;
+ this.eventPublisher = eventPublisher;
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
this.schemaTtl = Duration.ofDays(schemaTtlDays);
@@ -121,6 +126,8 @@ public class AchievementSyncService {
}
} finally {
runningUsers.remove(userId);
+ // Also after a failed run: the sync is committed, its followers must still hear of it.
+ eventPublisher.publishEvent(new LibraryActivityEvent(userId));
}
// A request can arrive after the last remove() and before the user was released.
if (!requestedRuns.containsKey(userId)) return;
diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java
index 67a07bf..646a328 100644
--- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java
+++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java
@@ -127,8 +127,10 @@ public class LibrarySyncWorker {
// Before the empty check: an account without games can still have a public wishlist.
Optional wishlist = fetchWishlist(steamId, games);
boolean saved = transaction.execute(tx -> {
- wishlist.ifPresentOrElse(found -> saveWishlist(userId, found),
- () -> log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId));
+ List wishlistAdds = wishlist.map(found -> saveWishlist(userId, found)).orElseGet(() -> {
+ log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId);
+ return List.of();
+ });
if (games.isEmpty()) {
// A private "Game details" setting ends here. A soft-delete would wipe the library.
log.warn("Sync user={} got no games for SteamID {}", userId, steamId);
@@ -140,6 +142,7 @@ public class LibrarySyncWorker {
int imported = (int) changes.stream().filter(c -> c.kind() == SyncChangeKind.ADDED).count();
syncChangeRepository.replace(userId, syncStartedAt, trigger, imported, List.of());
} else {
+ changes.addAll(wishlistAdds);
syncChangeRepository.replace(userId, syncStartedAt, trigger, 0, changes);
}
return true;
@@ -222,7 +225,10 @@ public class LibrarySyncWorker {
});
}
- private void saveWishlist(Long userId, Wishlist wishlist) {
+ /**
+ * @return a WISHLIST change for each appid that was not on the wishlist before
+ */
+ private List saveWishlist(Long userId, Wishlist wishlist) {
// A failed store batch leaves apps out of `store`; they keep the name and price of the last sync.
Map previous = wishlistGameRepository.findForUser(userId).stream()
.collect(Collectors.toMap(WishlistGame::getAppid, w -> w));
@@ -239,6 +245,8 @@ public class LibrarySyncWorker {
: new WishlistGame(userId, item.appid(), old.getName(), item.addedAt(),
old.getPrice(), old.getOriginalPrice(), old.getDiscountPct());
}).toList());
+ return wishlist.wanted().stream().map(SteamApiService.WishlistItem::appid)
+ .filter(appid -> !previous.containsKey(appid)).map(SyncChange::wishlist).toList();
}
private void upsertGame(SteamOwnedGamesResponse.Game g, SteamGame existing, Instant now, Set correctedNames) {
diff --git a/src/main/java/core_service/shared/event/LibraryActivityEvent.java b/src/main/java/core_service/shared/event/LibraryActivityEvent.java
new file mode 100644
index 0000000..1458d13
--- /dev/null
+++ b/src/main/java/core_service/shared/event/LibraryActivityEvent.java
@@ -0,0 +1,7 @@
+package core_service.shared.event;
+
+/**
+ * The sync of the user and its achievement run are done.
+ */
+public record LibraryActivityEvent(long userId) {
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 0cc0243..1cddc4d 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -75,6 +75,13 @@ app:
rewards:
# Pile Points of every user, after the nightly sync (23:55) and the image repair (01:30).
settle-cron: "0 30 2 * * *"
+ follow:
+ # The hand changes of a followed user are one batch after this quiet time.
+ digest-ms: 600000
+ quiet-minutes: 10
+ inbox:
+ purge-cron: "0 15 3 * * *"
+ keep-days: 90
account-cleanup:
# Log only, delete nothing. Auth's nightly cleanup drives this; see the Deployment README before flipping it.
dry-run: ${APP_ACCOUNT_CLEANUP_DRY_RUN:true}
diff --git a/src/main/resources/db/migration/V36__Follow.sql b/src/main/resources/db/migration/V36__Follow.sql
new file mode 100644
index 0000000..ad1c3a3
--- /dev/null
+++ b/src/main/resources/db/migration/V36__Follow.sql
@@ -0,0 +1,36 @@
+-- Users with a public profile (#27), pushed by Auth like listed_user: a row means public. sections: the parts
+-- of the profile the user shows (the names of the profile ticket); NULL from an Auth that does not send them
+-- means all. The digest leaves out what the profile hides.
+CREATE TABLE public_user (user_id BIGINT PRIMARY KEY, sections VARCHAR(16)[]);
+-- user_id follows followed_user_id. Ends for good when the followed user goes private, is blocked or deleted.
+CREATE TABLE follow (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ followed_user_id BIGINT NOT NULL CHECK (followed_user_id <> user_id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (user_id, followed_user_id)
+);
+CREATE INDEX idx_follow_followed ON follow (followed_user_id);
+-- How far the followers of user_id were told: the last reported sync result, status change and wishlist add
+-- (sync_change.id: an add can join a result that was reported already).
+CREATE TABLE follow_digest (
+ user_id BIGINT PRIMARY KEY,
+ sync_started_at TIMESTAMPTZ,
+ status_change_id BIGINT NOT NULL DEFAULT 0,
+ wishlist_change_id BIGINT NOT NULL DEFAULT 0
+);
+CREATE TABLE inbox_note (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL, -- the reader
+ kind VARCHAR(16) NOT NULL CHECK (kind IN ('DIGEST', 'VISIBILITY')),
+ subject_user_id BIGINT NOT NULL, -- never sent to the client
+ subject_name VARCHAR(100), -- snapshot at write time
+ new_games INTEGER NOT NULL DEFAULT 0,
+ achievements INTEGER NOT NULL DEFAULT 0,
+ status_changes INTEGER NOT NULL DEFAULT 0,
+ wishlist_adds INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ read_at TIMESTAMPTZ
+);
+CREATE INDEX idx_inbox_note_user ON inbox_note (user_id, created_at DESC);
+CREATE INDEX idx_inbox_note_subject ON inbox_note (subject_user_id);
diff --git a/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java b/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java
index 0094c7b..9df670f 100644
--- a/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java
+++ b/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java
@@ -47,6 +47,7 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID);
jdbc.update("delete from blocked_user where user_id in (?, 42)", USER_ID);
jdbc.update("delete from listed_user where user_id in (?, 42)", USER_ID);
+ jdbc.update("delete from public_user where user_id in (?, 42)", USER_ID);
jdbc.update("delete from account_name where user_id in (?, 42)", USER_ID);
jdbc.update("delete from account_role_change where user_id in (?, 42)", USER_ID);
}
@@ -145,6 +146,59 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
assertThat(jdbc.queryForList("select user_id from listed_user where user_id = 42", Long.class)).containsExactly(42L);
}
+ @Test
+ void stateCallWithPublicProfileAndSections_StoresThem() throws Exception {
+ JsonNode call = ContractTokens.internalCall("applyStates");
+
+ send(contractRequest(call), call.path("request").toString(), serviceToken()).andExpect(status().isNoContent());
+
+ // The sample is a blocked user: blocked wins, no public row.
+ assertThat(jdbc.queryForList("select user_id from public_user where user_id = 42", Long.class)).isEmpty();
+ send(post("/internal/accounts/states"), "{\"accounts\":[{\"userId\":" + USER_ID
+ + ",\"blocked\":false,\"publicProfile\":true,\"profileSections\":[\"SUMMARY\",\"STATS\"]}]}", serviceToken())
+ .andExpect(status().isNoContent());
+
+ assertThat(jdbc.queryForObject("select array_to_string(sections, ',') from public_user where user_id = ?",
+ String.class, USER_ID)).isEqualTo("SUMMARY,STATS");
+ }
+
+ @Test
+ void aLongUnknownSectionNameOfOneUserDoesNotRejectTheStatesOfOthers() throws Exception {
+ send(post("/internal/accounts/states"), "{\"accounts\":["
+ + "{\"userId\":42,\"blocked\":false,\"publicProfile\":true,\"profileSections\":[\"SUMMARY\",\"ABCDEFGHIJKLMNOPQ\"]},"
+ + "{\"userId\":" + USER_ID + ",\"blocked\":true}]}", serviceToken())
+ .andExpect(status().isNoContent());
+
+ assertThat(jdbc.queryForList("select user_id from blocked_user where user_id = ?", Long.class, USER_ID))
+ .containsExactly(USER_ID);
+ assertThat(jdbc.queryForObject("select array_to_string(sections, ',') from public_user where user_id = 42",
+ String.class)).isEqualTo("SUMMARY");
+ }
+
+ @Test
+ void aNullSectionNameOfOneUserDoesNotRejectTheStatesOfOthers() throws Exception {
+ send(post("/internal/accounts/states"), "{\"accounts\":["
+ + "{\"userId\":42,\"blocked\":false,\"publicProfile\":true,\"profileSections\":[\"SUMMARY\",null]},"
+ + "{\"userId\":" + USER_ID + ",\"blocked\":true}]}", serviceToken())
+ .andExpect(status().isNoContent());
+
+ assertThat(jdbc.queryForList("select user_id from blocked_user where user_id = ?", Long.class, USER_ID))
+ .containsExactly(USER_ID);
+ assertThat(jdbc.queryForObject("select array_to_string(sections, ',') from public_user where user_id = 42",
+ String.class)).isEqualTo("SUMMARY");
+ }
+
+ @Test
+ void stateCallWithoutPublicProfile_AnOldAuth_StillWorksAndKeepsTheRow() throws Exception {
+ jdbc.update("insert into public_user (user_id) values (?)", USER_ID);
+
+ send(post("/internal/accounts/states"), "{\"accounts\":[{\"userId\":" + USER_ID + ",\"blocked\":false}]}",
+ serviceToken()).andExpect(status().isNoContent());
+
+ assertThat(jdbc.queryForList("select user_id from public_user where user_id = ?", Long.class, USER_ID))
+ .containsExactly(USER_ID);
+ }
+
@Test
void stateCallWithoutARoleChange_AnOldAuth_StoresTheBlockAndNoRoleChange() throws Exception {
send(post("/internal/accounts/states"), "{\"accounts\":[{\"userId\":" + USER_ID + ",\"blocked\":true}]}",
diff --git a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java
index 7bfa8b0..84f8500 100644
--- a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java
+++ b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java
@@ -18,7 +18,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
- "reward_equipped", "listed_user");
+ "reward_equipped", "listed_user", "public_user", "follow", "follow_digest", "inbox_note");
@Autowired
private AccountCleanupRepository repository;
@@ -51,6 +51,10 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
jdbc.update("insert into game_link_request (user_id, appid_a, appid_b) values (?, 220, 620)", userId);
jdbc.update("insert into account_name (user_id, display_name) values (?, 'lobo')", userId);
jdbc.update("insert into listed_user (user_id) values (?)", userId);
+ jdbc.update("insert into public_user (user_id) values (?)", userId);
+ jdbc.update("insert into follow_digest (user_id) values (?)", userId);
+ jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", userId, userId + 100);
+ jdbc.update("insert into inbox_note (user_id, kind, subject_user_id) values (?, 'DIGEST', ?)", userId, userId + 100);
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId);
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'STARTER', 20, now())", userId);
jdbc.update("insert into reward_equipped (user_id, slot, item_code) values (?, 'BADGE', 'BADGE_PILE_SLAYER')", userId);
@@ -76,6 +80,29 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
assertThat(jdbc.queryForObject("select user_seen_at from user_steam_sync where user_id = 2", Object.class)).isNotNull();
}
+ @Test
+ void addDeleted_EndsFollowsInBothDirectionsWithoutANote() {
+ jdbc.update("insert into follow (user_id, followed_user_id) values (2, 1)");
+
+ repository.addDeleted(List.of(1L));
+
+ assertThat(jdbc.queryForList("select user_id from follow where user_id in (1, 2) or followed_user_id in (1, 2)",
+ Long.class)).containsExactly(2L);
+ assertThat(rows("follow_digest", 1L)).isZero();
+ assertThat(rows("public_user", 1L)).isZero();
+ assertThat(jdbc.queryForObject("select count(*) from inbox_note where kind = 'VISIBILITY'", Integer.class)).isZero();
+ }
+
+ @Test
+ void deleteRows_AlsoDropsTheNotesAboutTheUser() {
+ jdbc.update("insert into inbox_note (user_id, kind, subject_user_id) values (2, 'VISIBILITY', 1)");
+
+ repository.deleteRows(List.of(1L));
+
+ assertThat(jdbc.queryForObject("select count(*) from inbox_note where subject_user_id = 1", Integer.class)).isZero();
+ assertThat(jdbc.queryForObject("select count(*) from inbox_note where user_id = 2", Integer.class)).isEqualTo(1);
+ }
+
@Test
void replaceDeleted_KeepsOnlyTheGivenUsers() {
repository.addDeleted(List.of(1L, 2L));
@@ -110,7 +137,10 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
Map deletedRows = repository.deleteRows(List.of(1L));
- USER_TABLES.forEach(table -> assertThat(deletedRows.get(table)).as(table).isEqualTo(1));
+ // addDeleted already ended the follows and dropped the public profile.
+ List endedByAddDeleted = List.of("public_user", "follow", "follow_digest");
+ USER_TABLES.stream().filter(table -> !endedByAddDeleted.contains(table))
+ .forEach(table -> assertThat(deletedRows.get(table)).as(table).isEqualTo(1));
assertThat(deletedRows.get("deleted_user")).isEqualTo(1);
USER_TABLES.forEach(table -> assertThat(rows(table, 1L)).as(table).isZero());
USER_TABLES.forEach(table -> assertThat(rows(table, 2L)).as(table).isEqualTo(1));
diff --git a/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java
index d30aa60..ce44e02 100644
--- a/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java
+++ b/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java
@@ -32,6 +32,9 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
void seed() {
jdbc.update("delete from blocked_user");
jdbc.update("delete from listed_user");
+ jdbc.update("delete from public_user");
+ jdbc.update("delete from follow");
+ jdbc.update("delete from inbox_note");
jdbc.update("delete from account_name");
jdbc.update("delete from account_role_change");
for (long userId : List.of(1L, 2L)) {
@@ -51,24 +54,24 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test
void displayName_IsStoredAndReplacedAndAStateWithoutOneKeepsIt() {
- repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", false, null, null));
- repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", false, null, null, null, null));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null, null, null));
assertThat(jdbc.queryForObject("select display_name from account_name where user_id = 1", String.class)).isEqualTo("lobo");
- repository.apply(new AccountState(1L, OLD_STEAM_ID, "wolf", false, null, null));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, "wolf", false, null, null, null, null));
assertThat(jdbc.queryForList("select display_name from account_name", String.class)).containsExactly("wolf");
}
@Test
void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() {
- repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null, null));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null, null, null, null));
assertThat(blocked()).containsExactly(1L);
assertThat(sync(1L).get("user_seen_at")).isNull();
assertThat(sync(2L).get("user_seen_at")).isNotNull();
- repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null, null, null));
assertThat(blocked()).isEmpty();
}
@@ -76,7 +79,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
// steam_id stays: the next sync with the new ID compares with it and drops the old account's games.
@Test
void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() {
- repository.apply(new AccountState(1L, NEW_STEAM_ID, null, false, null, null));
+ repository.apply(new AccountState(1L, NEW_STEAM_ID, null, false, null, null, null, null));
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
assertThat(sync(1L).get("last_synced_at")).isNull();
@@ -85,14 +88,14 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test
void sameSteamId_KeepsTheLastSync() {
- repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null, null, null));
assertThat(sync(1L).get("last_synced_at")).isNotNull();
}
@Test
void noSteamId_StopsTheNightlySync() {
- repository.apply(new AccountState(1L, null, null, false, null, null));
+ repository.apply(new AccountState(1L, null, null, false, null, null, null, null));
assertThat(sync(1L).get("user_seen_at")).isNull();
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
@@ -100,7 +103,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test
void userWithoutASyncRow_GetsOnlyTheBlock() {
- repository.apply(new AccountState(3L, NEW_STEAM_ID, null, true, null, null));
+ repository.apply(new AccountState(3L, NEW_STEAM_ID, null, true, null, null, null, null));
assertThat(blocked()).containsExactly(3L);
assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty();
@@ -108,10 +111,10 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test
void replaceAll_LiftsTheBlocksAuthNoLongerSends() {
- repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null, null));
- repository.apply(new AccountState(2L, OLD_STEAM_ID, null, true, null, null));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null, null, null, null));
+ repository.apply(new AccountState(2L, OLD_STEAM_ID, null, true, null, null, null, null));
- repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, true, null, null)));
+ repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, true, null, null, null, null)));
assertThat(blocked()).containsExactly(2L);
repository.replaceAll(List.of());
@@ -128,13 +131,13 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
Instant first = Instant.parse("2026-10-04T12:00:00Z");
Instant second = first.plusSeconds(60);
- repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first, null));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first, null, null, null));
assertThat(changedAt(1L)).isEqualTo(first);
- repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null, null, null));
assertThat(changedAt(1L)).isEqualTo(first);
- repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second, null));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second, null, null, null));
assertThat(changedAt(1L)).isEqualTo(second);
}
@@ -143,8 +146,8 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
Instant first = Instant.parse("2026-10-04T12:00:01Z");
Instant second = first.plusSeconds(1);
- repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second, null));
- repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first, null));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second, null, null, null));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first, null, null, null));
assertThat(changedAt(1L)).isEqualTo(second);
}
@@ -152,8 +155,8 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test
void findLoginState_ReadsTheBlockAndTheRoleChange() {
Instant change = Instant.parse("2026-10-04T12:00:00Z");
- repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, change, null));
- repository.apply(new AccountState(2L, OLD_STEAM_ID, null, false, null, null));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, change, null, null, null));
+ repository.apply(new AccountState(2L, OLD_STEAM_ID, null, false, null, null, null, null));
assertThat(repository.findLoginState(1L)).isEqualTo(new LoginState(true, change));
assertThat(repository.findLoginState(2L)).isEqualTo(LoginState.NONE);
@@ -166,33 +169,169 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test
void listed_TrueInsertsFalseDeletesNullKeeps() {
- repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, true));
- repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, true, null, null));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, null, null, null));
assertThat(listed()).containsExactly(1L);
- repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, false));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, false, null, null));
assertThat(listed()).isEmpty();
}
@Test
void replaceAll_ListsExactlyTheListedUsers() {
- repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, true));
- repository.apply(new AccountState(2L, OLD_STEAM_ID, null, false, null, true));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, true, null, null));
+ repository.apply(new AccountState(2L, OLD_STEAM_ID, null, false, null, true, null, null));
- repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, false, null, true),
- new AccountState(3L, OLD_STEAM_ID, null, false, null, false)));
+ repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, false, null, true, null, null),
+ new AccountState(3L, OLD_STEAM_ID, null, false, null, false, null, null)));
assertThat(listed()).containsExactly(2L);
- repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, false, null, false)));
+ repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, false, null, false, null, null)));
assertThat(listed()).isEmpty();
}
@Test
void replaceAll_WithAStateWithoutListing_KeepsTheListing() {
- repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, true));
+ repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null, true, null, null));
- repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, false, null, null)));
+ repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, false, null, null, null, null)));
assertThat(listed()).containsExactly(1L);
}
+
+ private static AccountState publicState(long userId, Boolean publicProfile, List sections) {
+ return new AccountState(userId, OLD_STEAM_ID, null, false, null, null, publicProfile, sections);
+ }
+
+ private void follows(long follower, long followed) {
+ jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", follower, followed);
+ }
+
+ private List sections(long userId) {
+ return jdbc.query("select sections from public_user where user_id = ?", (rs, i) -> {
+ java.sql.Array array = rs.getArray("sections");
+ try {
+ return array == null ? null : java.util.Arrays.asList((String[]) array.getArray());
+ } catch (java.sql.SQLException e) {
+ throw new IllegalStateException(e);
+ }
+ }, userId).get(0);
+ }
+
+ private List