diff --git a/readme.md b/readme.md
index 9793950..bbbc692 100644
--- a/readme.md
+++ b/readme.md
@@ -205,9 +205,10 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/public/profiles/library/stats/games?key=&arg=&page=` (`403 NOT_SHARED`, `400 BAD_ARG`) | profile ticket |
| `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket |
| `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 |
| `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}]}`, `displayName` and `roleChangedAt` optional, `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}]}`, `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` | `/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 |
@@ -254,9 +255,9 @@ flowchart LR
Igdb -->|"OAuth token"| Twitch["Twitch"]
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"| Db
+ 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
Auth -->|"service token /internal/accounts"| Account
- Account -->|"per-user rows, deleted_user, blocked_user, account_name, user_seen_at"| Db
+ Account -->|"per-user rows, deleted_user, blocked_user, listed_user, account_name, user_seen_at"| Db
```
| Module | Content |
@@ -264,13 +265,13 @@ 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`, role changes in `account_role_change`, display names in `account_name`, Steam ID changes stop the nightly sync |
+| `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 |
| `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`, `counted_user_game`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
+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
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.
@@ -448,12 +449,12 @@ 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 purge also removes the user's `account_name` row and link requests (`game_link_request`).
+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 and an unblock; 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); 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 7a8ba4e..5b75568 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,7 @@ 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.blocked(), account.roleChangedAt(), account.listed()))
.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 b152ce2..44e01f8 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
@@ -12,8 +12,9 @@ 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
*/
public record AccountStateDto(@NotNull Long userId, Long steamId, @Size(max = 100) String displayName,
- boolean blocked, Instant roleChangedAt) {
+ boolean blocked, Instant roleChangedAt, Boolean listed) {
}
}
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 cb5d402..9af3bd5 100644
--- a/src/main/java/core_service/modules/account/domain/AccountState.java
+++ b/src/main/java/core_service/modules/account/domain/AccountState.java
@@ -8,6 +8,8 @@ import java.time.Instant;
* @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link)
* @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
*/
-public record AccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt) {
+public record AccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt,
+ Boolean listed) {
}
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 71d0237..4561be3 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", "deleted_user");
+ "account_role_change", "blocked_user", "listed_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", "account_role_change", "deleted_user", "account_name");
+ "game_import", "blocked_user", "listed_user", "account_role_change", "deleted_user", "account_name");
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
index da9ef3a..371ba07 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,8 @@ import java.util.Collection;
import java.util.List;
/**
- * Blocks in blocked_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, 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
@@ -41,6 +41,11 @@ public class AccountStateRepository {
} else {
jdbc.update("delete from blocked_user where user_id = :userId", params);
}
+ if (Boolean.TRUE.equals(state.listed())) {
+ jdbc.update("insert into listed_user (user_id) values (:userId) on conflict do nothing", params);
+ } else if (Boolean.FALSE.equals(state.listed())) {
+ jdbc.update("delete from listed_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);
@@ -65,7 +70,8 @@ public class AccountStateRepository {
}
/**
- * After this, exactly the blocked users of the list are blocked.
+ * After this, exactly the blocked users of the list are blocked, and the listed ones listed unless a state has no
+ * listing.
*/
public void replaceAll(Collection states) {
states.forEach(this::apply);
@@ -75,5 +81,13 @@ public class AccountStateRepository {
} else {
jdbc.update("delete from blocked_user where user_id not in (:ids)", new MapSqlParameterSource("ids", blocked));
}
+ 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));
+ }
+ }
}
}
diff --git a/src/main/java/core_service/modules/stats/api/OwnerListController.java b/src/main/java/core_service/modules/stats/api/OwnerListController.java
new file mode 100644
index 0000000..e3314cf
--- /dev/null
+++ b/src/main/java/core_service/modules/stats/api/OwnerListController.java
@@ -0,0 +1,40 @@
+package core_service.modules.stats.api;
+
+import core_service.modules.stats.api.dto.PeopleDto;
+import core_service.modules.stats.domain.CommunityKey;
+import core_service.modules.stats.domain.OwnerList;
+import core_service.modules.stats.service.OwnerListService;
+import core_service.shared.api.ErrorResponse;
+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.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/stats")
+@RequiredArgsConstructor
+public class OwnerListController {
+
+ private final OwnerListService ownerListService;
+
+ @GetMapping("/owners")
+ public ResponseEntity> owners(@AuthenticationPrincipal UserPrincipal principal, @RequestParam long appid,
+ @RequestParam(defaultValue = "OWNED") CommunityKey key,
+ @RequestParam(required = false) String arg,
+ @RequestParam(defaultValue = "0") int page) {
+ return key.parseArg(arg)
+ .>map(parsed -> ResponseEntity.ok(toDto(
+ ownerListService.owners(principal.getIdAsLong(), appid, key, parsed, page))))
+ .orElseGet(() -> ResponseEntity.badRequest()
+ .body(new ErrorResponse("BAD_ARG", "The argument does not fit the key")));
+ }
+
+ private static PeopleDto toDto(OwnerList owners) {
+ return new PeopleDto(owners.listedNames().stream().map(PeopleDto.Person::new).toList(), owners.listedTotal(),
+ owners.privateCount(), owners.includesYou(), owners.page(), owners.totalPages());
+ }
+}
diff --git a/src/main/java/core_service/modules/stats/api/dto/PeopleDto.java b/src/main/java/core_service/modules/stats/api/dto/PeopleDto.java
new file mode 100644
index 0000000..7c018ee
--- /dev/null
+++ b/src/main/java/core_service/modules/stats/api/dto/PeopleDto.java
@@ -0,0 +1,14 @@
+package core_service.modules.stats.api.dto;
+
+import java.util.List;
+
+/**
+ * Who stands behind a number. A listed user is a display name and nothing else, never an id or an avatar (#26).
+ * The viewer is in neither count, only in {@code includesYou}.
+ */
+public record PeopleDto(List listed, long listedTotal, long privateCount, boolean includesYou, int page,
+ int totalPages) {
+
+ public record Person(String displayName) {
+ }
+}
diff --git a/src/main/java/core_service/modules/stats/domain/OwnerList.java b/src/main/java/core_service/modules/stats/domain/OwnerList.java
new file mode 100644
index 0000000..d1712d9
--- /dev/null
+++ b/src/main/java/core_service/modules/stats/domain/OwnerList.java
@@ -0,0 +1,10 @@
+package core_service.modules.stats.domain;
+
+import java.util.List;
+
+/**
+ * One page of the listed owners of a game. The viewer is in neither count.
+ */
+public record OwnerList(List listedNames, long listedTotal, long privateCount, boolean includesYou, int page,
+ int totalPages) {
+}
diff --git a/src/main/java/core_service/modules/stats/domain/PeopleCounts.java b/src/main/java/core_service/modules/stats/domain/PeopleCounts.java
new file mode 100644
index 0000000..eab4fe6
--- /dev/null
+++ b/src/main/java/core_service/modules/stats/domain/PeopleCounts.java
@@ -0,0 +1,8 @@
+package core_service.modules.stats.domain;
+
+/**
+ * @param others the owners without the viewer
+ * @param listed how many of {@code others} are shown by name
+ */
+public record PeopleCounts(long others, long listed, boolean includesYou) {
+}
diff --git a/src/main/java/core_service/modules/stats/repo/StatsRepository.java b/src/main/java/core_service/modules/stats/repo/StatsRepository.java
index bf50931..c14ef65 100644
--- a/src/main/java/core_service/modules/stats/repo/StatsRepository.java
+++ b/src/main/java/core_service/modules/stats/repo/StatsRepository.java
@@ -1,6 +1,7 @@
package core_service.modules.stats.repo;
import core_service.modules.stats.domain.CommunityKey;
+import core_service.modules.stats.domain.PeopleCounts;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
@@ -20,24 +21,24 @@ public class StatsRepository {
* behind it read the same rows.
*/
private static final String ENTRIES_SET =
- "SELECT appid, playtime_minutes FROM counted_user_game WHERE " + NOT_DELETED_OR_BLOCKED;
+ "SELECT user_id, appid, playtime_minutes FROM counted_user_game WHERE " + NOT_DELETED_OR_BLOCKED;
private static final String PLAYED_SET = ENTRIES_SET + " AND playtime_minutes > 0";
private static final String OWNED_SET =
- "SELECT appid, playtime_minutes FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED;
+ "SELECT user_id, appid, playtime_minutes FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED;
private static final String PLATFORM_SET = """
- SELECT appid, playtime_minutes,
+ SELECT user_id, appid, playtime_minutes,
CASE WHEN source = 'STEAM' THEN 'Steam' ELSE TRIM(platform) END AS name
FROM listed_user_game WHERE %s
""".formatted(NOT_DELETED_OR_BLOCKED);
private static final String GENRE_SET = """
- SELECT ug.appid, ug.playtime_minutes, tag.name AS name
+ SELECT ug.user_id, ug.appid, ug.playtime_minutes, tag.name AS name
FROM listed_user_game ug JOIN game_mood m ON m.appid = ug.appid
CROSS JOIN UNNEST(m.genres) AS tag(name)
WHERE %s
""".formatted(NOT_DELETED_OR_BLOCKED);
/** Has one {@code ?}: the time zone. */
private static final String FINISHED_SET = """
- SELECT ug.appid, ug.playtime_minutes,
+ SELECT ug.user_id, ug.appid, ug.playtime_minutes,
CAST(EXTRACT(YEAR FROM COALESCE(ug.finished_at_estimate, f.finished_at) AT TIME ZONE ?)
AS INTEGER) AS year
FROM counted_user_game ug
@@ -57,10 +58,10 @@ public class StatsRepository {
CommunityKey.PLAYED, PLAYED_SET,
CommunityKey.OWNED, OWNED_SET,
CommunityKey.MOST_PLAYED, OWNED_SET,
- CommunityKey.PLATFORM, "SELECT appid, playtime_minutes FROM (" + NAMED_PLATFORM
+ CommunityKey.PLATFORM, "SELECT user_id, appid, playtime_minutes FROM (" + NAMED_PLATFORM
+ ") g WHERE LOWER(g.name) = LOWER(TRIM(?))",
- CommunityKey.GENRE, "SELECT appid, playtime_minutes FROM (" + GENRE_SET + ") g WHERE g.name = ?",
- CommunityKey.FINISHED_YEAR, "SELECT appid, playtime_minutes FROM (" + FINISHED_SET + ") g WHERE g.year = ?");
+ CommunityKey.GENRE, "SELECT user_id, appid, playtime_minutes FROM (" + GENRE_SET + ") g WHERE g.name = ?",
+ CommunityKey.FINISHED_YEAR, "SELECT user_id, appid, playtime_minutes FROM (" + FINISHED_SET + ") g WHERE g.year = ?");
private final JdbcTemplate jdbc;
@@ -203,6 +204,43 @@ public class StatsRepository {
return v != null ? v : 0L;
}
+ public long keptAppid(long appid) {
+ Long kept = jdbc.queryForObject(
+ "SELECT COALESCE((SELECT to_appid FROM game_alias WHERE from_appid = ?), ?)", Long.class, appid, appid);
+ return kept != null ? kept : appid;
+ }
+
+ /**
+ * The owners of a game in the set behind one community number, without the viewer. Listed means a listed_user
+ * row and a stored name; everyone else is private.
+ */
+ public PeopleCounts countOwners(CommunityKey key, Object arg, String zone, long appid, long viewerId) {
+ List