diff --git a/readme.md b/readme.md
index 93530f0..370e075 100644
--- a/readme.md
+++ b/readme.md
@@ -129,6 +129,7 @@ method calling another service's method that needs more.
| `GET` | `/api/public/profiles/library` | profile ticket |
| `GET` | `/api/stats/public` | public |
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
+| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, blocked}]}`; 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 |
@@ -173,9 +174,9 @@ flowchart LR
Igdb -->|"OAuth token"| Twitch["Twitch"]
Speedrun --> SpeedrunApi["speedrun.com API"]
LibRepo --> Db[("PostgreSQL tbp_core")]
- Stats -->|"listed_user_game, deleted_user"| Db
+ Stats -->|"listed_user_game, deleted_user, blocked_user"| Db
Auth -->|"service token
/internal/accounts"| Account
- Account -->|"per-user rows, deleted_user"| Db
+ Account -->|"per-user rows, deleted_user, blocked_user, user_seen_at"| Db
```
| Module | Content |
@@ -183,15 +184,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` | Deleted accounts, driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows |
+| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, 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 |
**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_game`, `steam_game` and `deleted_user`; `account` deletes
-the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes into
-its list); `library` reads `deleted_user`. A module that moves to its own service takes these reads along as calls.
+SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game`, `deleted_user` and `blocked_user`; `account`
+deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
+into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
+reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls.
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view.
@@ -222,7 +224,7 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
dates finished games by the unlock of their ending achievement
(`FinishedEstimateService`, `user_game.finished_at_estimate`).
3. `LibrarySyncWorker` runs on the background pool. `user_steam_sync` lets only one run per user start, and none for
- a user in `deleted_user`.
+ a user in `deleted_user` or `blocked_user`.
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings
@@ -310,6 +312,16 @@ flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned
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.
+### 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 block and an unblock; 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. The access token still works in Core until
+it expires (max 15 min). A new Steam ID or no Steam ID takes the user out of the nightly sync; the next visit syncs
+the ID of the token (Core's copy changes only then, so that sync sees the change and drops the old account's games). A visit with an access token that still has the old ID (max 15 min) can sync it once
+more and put the user back on the nightly list; the nightly `PUT` at 04:15 takes the user off again, so at most one
+nightly sync (23:55) runs with the old ID. Unblock and the nightly `PUT` lift the block.
+
### Manual games next to Steam games
A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games and manual games share
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 aca473a..24b6a6c 100644
--- a/src/main/java/core_service/modules/account/api/InternalAccountController.java
+++ b/src/main/java/core_service/modules/account/api/InternalAccountController.java
@@ -1,11 +1,14 @@
package core_service.modules.account.api;
+import core_service.modules.account.api.dto.AccountStatesRequest;
import core_service.modules.account.api.dto.CleanupResultDto;
import core_service.modules.account.api.dto.KnownUsersRequest;
import core_service.modules.account.api.dto.PurgeRequest;
import core_service.modules.account.api.dto.UserIdsRequest;
+import core_service.modules.account.domain.AccountState;
import core_service.modules.account.domain.CleanupResult;
import core_service.modules.account.service.AccountCleanupService;
+import core_service.modules.account.service.AccountStateService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
@@ -15,12 +18,15 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+import java.util.List;
+
@RestController
@RequestMapping("/internal/accounts")
@RequiredArgsConstructor
public class InternalAccountController {
private final AccountCleanupService accountCleanupService;
+ private final AccountStateService accountStateService;
@PostMapping("/deleted")
public ResponseEntity markDeleted(@Valid @RequestBody UserIdsRequest request) {
@@ -44,6 +50,24 @@ public class InternalAccountController {
return ResponseEntity.ok(toDto(accountCleanupService.prune(request.knownUserIds(), request.dryRun())));
}
+ @PostMapping("/states")
+ public ResponseEntity applyStates(@Valid @RequestBody AccountStatesRequest request) {
+ accountStateService.apply(toStates(request));
+ return ResponseEntity.noContent().build();
+ }
+
+ @PutMapping("/states")
+ public ResponseEntity replaceStates(@Valid @RequestBody AccountStatesRequest request) {
+ accountStateService.replaceAll(toStates(request));
+ return ResponseEntity.noContent().build();
+ }
+
+ private static List toStates(AccountStatesRequest request) {
+ return request.accounts().stream()
+ .map(account -> new AccountState(account.userId(), account.steamId(), account.blocked()))
+ .toList();
+ }
+
private static CleanupResultDto toDto(CleanupResult result) {
return new CleanupResultDto(result.dryRun(), result.users(), result.rows(), result.refused());
}
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
new file mode 100644
index 0000000..6b55b1e
--- /dev/null
+++ b/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java
@@ -0,0 +1,12 @@
+package core_service.modules.account.api.dto;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.List;
+
+public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> accounts) {
+
+ public record AccountStateDto(@NotNull Long userId, Long steamId, boolean blocked) {
+ }
+}
diff --git a/src/main/java/core_service/modules/account/domain/AccountState.java b/src/main/java/core_service/modules/account/domain/AccountState.java
new file mode 100644
index 0000000..839a3c2
--- /dev/null
+++ b/src/main/java/core_service/modules/account/domain/AccountState.java
@@ -0,0 +1,9 @@
+package core_service.modules.account.domain;
+
+/**
+ * What Core copies from Auth for one user.
+ *
+ * @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link)
+ */
+public record AccountState(long userId, Long steamId, boolean blocked) {
+}
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 f412cf7..1b4f0f5 100644
--- a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java
+++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java
@@ -21,11 +21,11 @@ public class AccountCleanupRepository {
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
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",
- "user_steam_sync", "user_game", "game_import", "deleted_user");
+ "user_steam_sync", "user_game", "game_import", "blocked_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", "deleted_user");
+ "game_import", "blocked_user", "deleted_user");
private final NamedParameterJdbcTemplate jdbc;
diff --git a/src/main/java/core_service/modules/account/repo/AccountStateRepository.java b/src/main/java/core_service/modules/account/repo/AccountStateRepository.java
new file mode 100644
index 0000000..ac03481
--- /dev/null
+++ b/src/main/java/core_service/modules/account/repo/AccountStateRepository.java
@@ -0,0 +1,54 @@
+package core_service.modules.account.repo;
+
+import core_service.modules.account.domain.AccountState;
+import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Blocks in blocked_user, and what a Steam ID change means for user_steam_sync. Plain SQL, because the library
+ * module owns the entities.
+ */
+@Repository
+@RequiredArgsConstructor
+public class AccountStateRepository {
+
+ private final NamedParameterJdbcTemplate jdbc;
+
+ public void apply(AccountState state) {
+ MapSqlParameterSource params = new MapSqlParameterSource("userId", state.userId())
+ .addValue("steamId", state.steamId() == null ? null : String.valueOf(state.steamId()));
+ if (state.blocked()) {
+ jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params);
+ } else {
+ jdbc.update("delete from blocked_user where user_id = :userId", 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);
+ } else {
+ // A new ID: no nightly sync with the old one, and the next visit syncs at once. steam_id stays, so that
+ // sync sees the change and drops the old account's games.
+ jdbc.update("""
+ update user_steam_sync set user_seen_at = null, last_synced_at = null
+ where user_id = :userId and steam_id is distinct from :steamId""", params);
+ }
+ }
+
+ /**
+ * After this, exactly the blocked users of the list are blocked.
+ */
+ 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));
+ }
+ }
+}
diff --git a/src/main/java/core_service/modules/account/service/AccountStateService.java b/src/main/java/core_service/modules/account/service/AccountStateService.java
new file mode 100644
index 0000000..f340be4
--- /dev/null
+++ b/src/main/java/core_service/modules/account/service/AccountStateService.java
@@ -0,0 +1,39 @@
+package core_service.modules.account.service;
+
+import core_service.modules.account.domain.AccountState;
+import core_service.modules.account.repo.AccountStateRepository;
+import core_service.shared.security.Internal;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * Steam ID and block of each user, driven by Auth. Auth sends a change at once and the whole set nightly, so a
+ * missed call heals by the next night.
+ */
+@Service
+@Internal
+@RequiredArgsConstructor
+@Slf4j
+public class AccountStateService {
+
+ private final AccountStateRepository repository;
+
+ @Transactional
+ @CacheEvict(value = "publicStats", allEntries = true)
+ public void apply(List states) {
+ states.forEach(repository::apply);
+ log.info("Account state: {} users updated", states.size());
+ }
+
+ @Transactional
+ @CacheEvict(value = "publicStats", allEntries = true)
+ public void replaceAll(List states) {
+ repository.replaceAll(states);
+ log.info("Account state: {} users replaced", states.size());
+ }
+}
diff --git a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java
index db4e4e6..ff153b6 100644
--- a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java
+++ b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java
@@ -26,7 +26,8 @@ public interface UserSteamSyncRepository extends JpaRepository 0", Long.class);
+ "SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED + " AND playtime_minutes > 0", Long.class);
return v != null ? v : 0L;
}
public long sumPlaytimeMinutes() {
Long v = jdbc.queryForObject(
- "SELECT COALESCE(SUM(playtime_minutes), 0) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
+ "SELECT COALESCE(SUM(playtime_minutes), 0) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
return v != null ? v : 0L;
}
public long countUsersWithLibrary() {
Long v = jdbc.queryForObject(
- "SELECT COUNT(DISTINCT user_id) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
+ "SELECT COUNT(DISTINCT user_id) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
return v != null ? v : 0L;
}
@@ -55,7 +56,7 @@ public class StatsRepository {
SELECT COUNT(*) AS c FROM listed_user_game
WHERE %s GROUP BY user_id
) t
- """.formatted(NOT_DELETED), Long.class);
+ """.formatted(NOT_DELETED_OR_BLOCKED), Long.class);
return v != null ? v : 0L;
}
@@ -65,7 +66,7 @@ public class StatsRepository {
SELECT COUNT(*) AS c FROM listed_user_game
WHERE %s AND playtime_minutes = 0 GROUP BY user_id
) t
- """.formatted(NOT_DELETED), Long.class);
+ """.formatted(NOT_DELETED_OR_BLOCKED), Long.class);
return v != null ? v : 0L;
}
@@ -78,7 +79,7 @@ public class StatsRepository {
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
ORDER BY value DESC
LIMIT ?
- """.formatted(NOT_DELETED), limit);
+ """.formatted(NOT_DELETED_OR_BLOCKED), limit);
}
public List