diff --git a/readme.md b/readme.md
index 6755efb..b0ef529 100644
--- a/readme.md
+++ b/readme.md
@@ -52,7 +52,8 @@ To Be Played is split into three repositories:
40-hour weeks, oldest unplayed game); most played and most loyal game (most months with playtime); top IGDB genres
and moods; moon trips (Apollo 11, 195.3 h), longest day, share under one hour; finished games per year (from the
status history); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the
- first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games.
+ first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games (still owned, not hidden: the view
+ `listed_user_game`, V24).
- **Status history**: a database trigger logs every status change of a library game in `user_game_status_change`
(old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older
changes are not known.
@@ -172,9 +173,9 @@ flowchart LR
Igdb -->|"OAuth token"| Twitch["Twitch"]
Speedrun --> SpeedrunApi["speedrun.com API"]
LibRepo --> Db[("PostgreSQL tbp_core")]
- Stats --> Db
+ Stats -->|"listed_user_game, deleted_user"| Db
Auth -->|"service token
/internal/accounts"| Account
- Account --> Db
+ Account -->|"per-user rows, deleted_user"| Db
```
| Module | Content |
@@ -187,6 +188,13 @@ flowchart LR
| `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.
+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.
+
Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo`
(repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build:
diff --git a/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java b/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java
index bcb9f5b..5ec4ab6 100644
--- a/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java
+++ b/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java
@@ -22,8 +22,7 @@ public class BacklogSnapshotRepository {
count(*) filter (where ug.playtime_minutes = 0) as never_played,
coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG'), 0) as backlog_minutes,
count(*) as listed_games
- from user_game ug left join game_time_to_beat t on t.appid = ug.appid
- where ug.removed_at is null and ug.hidden_at is null
+ from listed_user_game ug left join game_time_to_beat t on t.appid = ug.appid
""";
private final NamedParameterJdbcTemplate jdbc;
@@ -37,7 +36,7 @@ public class BacklogSnapshotRepository {
return jdbc.update("""
insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, listed_games)
select ug.user_id, :day, %s
- and ug.user_id not in (select user_id from deleted_user)
+ where ug.user_id not in (select user_id from deleted_user)
group by ug.user_id
on conflict (user_id, day) do update set backlog_games = excluded.backlog_games,
never_played = excluded.never_played, backlog_minutes = excluded.backlog_minutes,
@@ -61,7 +60,7 @@ public class BacklogSnapshotRepository {
* The pile of now, for the day that has no row yet.
*/
public UserStats.PileDay live(long userId, LocalDate day) {
- return jdbc.queryForObject("select " + PILE_COLUMNS + "and ug.user_id = :userId",
+ return jdbc.queryForObject("select " + PILE_COLUMNS + "where ug.user_id = :userId",
new MapSqlParameterSource("userId", userId),
(rs, i) -> new UserStats.PileDay(day, rs.getLong("backlog_games"), rs.getLong("never_played"),
rs.getLong("backlog_minutes")));
diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java
index 02ead69..ec79b93 100644
--- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java
+++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java
@@ -39,7 +39,8 @@ public interface UserGameRepository extends JpaRepository
*/
String LISTED = "ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is null ";
- List findByUserIdAndRemovedAtIsNull(Long userId);
+ @Query("select ug from UserGame ug where " + LISTED + "and ug.source = core_service.modules.library.domain.GameSource.STEAM")
+ List findListedSteamGames(@Param("userId") Long userId);
List findByUserId(Long userId);
@@ -103,8 +104,7 @@ public interface UserGameRepository extends JpaRepository
*
* @param excludeAppid never picked; {@link #NO_GAME} excludes nothing
*/
- @Query(value = "select * from user_game where user_id = :userId and removed_at is null and hidden_at is null " +
- "and status = 'BACKLOG' " +
+ @Query(value = "select * from listed_user_game where user_id = :userId and status = 'BACKLOG' " +
"and appid <> :excludeAppid " +
// Weighted random pick: the smallest -ln(u) / weight wins.
"order by -ln(1 - random()) / case priority when 'HIGH' then 4 when 'MEDIUM' then 2 else 1 end " +
@@ -136,8 +136,8 @@ public interface UserGameRepository extends JpaRepository
return queryPickerGames(userId, maxMinutes, storedMood, speedrun, excluded, count);
}
- @Query(value = "select ug.* from user_game ug where ug.user_id = :userId and ug.removed_at is null " +
- "and ug.hidden_at is null and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " +
+ @Query(value = "select ug.* from listed_user_game ug where ug.user_id = :userId " +
+ "and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " +
"and (cast(:maxMinutes as integer) is null or exists (select 1 from game_time_to_beat t " +
" where t.appid = ug.appid and t.normally_minutes <= :maxMinutes)) " +
"and (cast(:mood as varchar) is null or exists (select 1 from game_mood m " +
@@ -220,7 +220,7 @@ public interface UserGameRepository extends JpaRepository
@Modifying(clearAutomatically = true)
@Transactional
@Query("update UserGame ug set ug.status = core_service.modules.library.domain.GameStatus.FINISHED " +
- "where ug.userId = :userId and ug.appid = :appid and ug.removedAt is null and ug.hiddenAt is null " +
+ "where " + LISTED + "and ug.appid = :appid " +
"and ug.statusSetByUser = false and ug.finishedHintDismissed is null " +
"and ug.source = core_service.modules.library.domain.GameSource.STEAM " +
"and ug.status in (core_service.modules.library.domain.GameStatus.BACKLOG, " +
@@ -253,7 +253,7 @@ public interface UserGameRepository extends JpaRepository
*/
@Modifying
@Query("update UserGame ug set ug.status = core_service.modules.library.domain.GameStatus.PLAYING " +
- "where ug.userId = :userId and ug.appid in :appids and ug.removedAt is null and ug.hiddenAt is null " +
+ "where " + LISTED + "and ug.appid in :appids " +
"and ug.statusSetByUser = false " +
"and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG")
int playFromPileIfUntouched(@Param("userId") Long userId, @Param("appids") Collection appids);
diff --git a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java
index 830024b..15afea8 100644
--- a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java
+++ b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java
@@ -23,7 +23,7 @@ import java.util.Optional;
@RequiredArgsConstructor
public class UserStatsRepository {
- private static final String LISTED = "ug.user_id = :userId and ug.removed_at is null and ug.hidden_at is null ";
+ private static final String OF_USER = "ug.user_id = :userId ";
private static final String FINISHED = "('FINISHED', 'COMPLETED')";
/** A status change that finished the game: from another status; not a game that came in finished. */
private static final String FINISH_CHANGE =
@@ -46,9 +46,9 @@ public class UserStatsRepository {
return jdbc.queryForObject("""
select coalesce(sum(t.normally_minutes), 0) as normal, coalesce(sum(t.completely_minutes), 0) as complete,
count(t.normally_minutes) as known
- from user_game ug join game_time_to_beat t on t.appid = ug.appid
+ from listed_user_game ug join game_time_to_beat t on t.appid = ug.appid
where %s and ug.status = 'BACKLOG'
- """.formatted(LISTED), user(userId),
+ """.formatted(OF_USER), user(userId),
(rs, i) -> new UserStats.PileTimes(rs.getLong("normal"), rs.getLong("complete"), rs.getLong("known")));
}
@@ -62,10 +62,10 @@ public class UserStatsRepository {
public Optional oldestUnplayed(long userId) {
return jdbc.query("""
select ug.appid, sg.name, sg.image_url, ug.first_seen_at
- from user_game ug join steam_game sg on sg.appid = ug.appid
+ from listed_user_game ug join steam_game sg on sg.appid = ug.appid
where %s and ug.status = 'BACKLOG' and ug.playtime_minutes = 0
order by ug.first_seen_at, ug.appid limit 1
- """.formatted(LISTED), user(userId),
+ """.formatted(OF_USER), user(userId),
(rs, i) -> new UserStats.OldestGame(rs.getLong("appid"), rs.getString("name"),
rs.getString("image_url"), rs.getTimestamp("first_seen_at").toInstant()))
.stream().findFirst();
@@ -74,10 +74,10 @@ public class UserStatsRepository {
public List mostPlayed(long userId, int limit) {
return jdbc.query("""
select ug.appid, sg.name, sg.image_url, ug.playtime_minutes
- from user_game ug join steam_game sg on sg.appid = ug.appid
+ from listed_user_game ug join steam_game sg on sg.appid = ug.appid
where %s and ug.playtime_minutes > 0
order by ug.playtime_minutes desc, ug.appid limit :limit
- """.formatted(LISTED), user(userId).addValue("limit", limit),
+ """.formatted(OF_USER), user(userId).addValue("limit", limit),
(rs, i) -> new UserStats.GameMinutes(rs.getLong("appid"), rs.getString("name"),
rs.getString("image_url"), rs.getLong("playtime_minutes")));
}
@@ -90,7 +90,7 @@ public class UserStatsRepository {
select l.appid, sg.name, sg.image_url, l.months, l.minutes
from (select p.appid, count(distinct date_trunc('month', p.day)) as months, sum(p.minutes) as minutes
%s group by p.appid) l
- join user_game ug on ug.user_id = :userId and ug.appid = l.appid and ug.removed_at is null
+ join listed_user_game ug on ug.user_id = :userId and ug.appid = l.appid
join steam_game sg on sg.appid = l.appid
order by l.months desc, l.minutes desc, l.appid limit 1
""".formatted(PLAYTIME_DAYS_NOT_HIDDEN), user(userId),
@@ -110,10 +110,10 @@ public class UserStatsRepository {
private List tagCounts(long userId, String column, int limit) {
return jdbc.query("""
select tag.name, count(*) as games, coalesce(sum(ug.playtime_minutes), 0) as minutes
- from user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name)
+ from listed_user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name)
where %s
group by tag.name order by minutes desc, games desc, tag.name limit :limit
- """.formatted(column, LISTED), user(userId).addValue("limit", limit),
+ """.formatted(column, OF_USER), user(userId).addValue("limit", limit),
(rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes")));
}
@@ -128,8 +128,8 @@ public class UserStatsRepository {
return jdbc.queryForObject("""
select count(*) filter (where ug.playtime_minutes > 0) as started,
count(*) filter (where ug.playtime_minutes between 1 and 59) as under_one_hour
- from user_game ug where %s
- """.formatted(LISTED), user(userId),
+ from listed_user_game ug where %s
+ """.formatted(OF_USER), user(userId),
(rs, i) -> new UserStats.PlayedCounts(rs.getLong("started"), rs.getLong("under_one_hour")));
}
@@ -141,20 +141,20 @@ public class UserStatsRepository {
return jdbc.query("""
select cast(extract(year from coalesce(ug.finished_at_estimate, f.finished_at) at time zone :zone)
as integer) as year, count(*) as games
- from user_game ug left join (%s) f on f.appid = ug.appid
+ from listed_user_game ug left join (%s) f on f.appid = ug.appid
where %s and ug.status in %s and coalesce(ug.finished_at_estimate, f.finished_at) is not null
group by year order by year
- """.formatted(FIRST_FINISH, LISTED, FINISHED), user(userId).addValue("zone", zone.getId()),
+ """.formatted(FIRST_FINISH, OF_USER, FINISHED), user(userId).addValue("zone", zone.getId()),
(rs, i) -> new UserStats.YearCount(rs.getInt("year"), rs.getLong("games")));
}
public long finishedBeforeTracking(long userId) {
Long games = jdbc.queryForObject("""
- select count(*) from user_game ug
+ select count(*) from listed_user_game ug
where %s and ug.status in %s and ug.finished_at_estimate is null
and not exists (select 1 from user_game_status_change sc
where sc.user_id = ug.user_id and sc.appid = ug.appid and %s)
- """.formatted(LISTED, FINISHED, FINISH_CHANGE), user(userId), Long.class);
+ """.formatted(OF_USER, FINISHED, FINISH_CHANGE), user(userId), Long.class);
return games != null ? games : 0L;
}
@@ -170,10 +170,10 @@ public class UserStatsRepository {
or sc.from_status is not null or sc.changed_at > uss.history_since)) as added,
count(distinct sc.appid) filter (where sc.from_status = 'BACKLOG' and sc.to_status <> 'BACKLOG')
as done
- from user_game_status_change sc join user_game ug on ug.user_id = sc.user_id and ug.appid = sc.appid
+ from user_game_status_change sc join listed_user_game ug on ug.user_id = sc.user_id and ug.appid = sc.appid
left join user_steam_sync uss on uss.user_id = sc.user_id
where %s and sc.changed_at >= :monthStart
- """.formatted(LISTED), user(userId).addValue("monthStart", Timestamp.from(monthStart)),
+ """.formatted(OF_USER), user(userId).addValue("monthStart", Timestamp.from(monthStart)),
(rs, i) -> new UserStats.MonthMoves(rs.getLong("added"), rs.getLong("done")));
}
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 4547088..190f5fa 100644
--- a/src/main/java/core_service/modules/library/service/AchievementSyncService.java
+++ b/src/main/java/core_service/modules/library/service/AchievementSyncService.java
@@ -3,7 +3,6 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.EndingAchievement;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
-import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievement;
@@ -163,8 +162,7 @@ public class AchievementSyncService {
}
private void runLibrarySync(Long userId, String steamId) {
- List games = userGameRepository.findByUserIdAndRemovedAtIsNull(userId).stream()
- .filter(game -> game.getSource() == GameSource.STEAM && game.getHiddenAt() == null)
+ List games = userGameRepository.findListedSteamGames(userId).stream()
.sorted(Comparator.comparingInt(UserGame::getPlaytimeMinutes).reversed())
.toList();
Map schemas = schemaMetaRepository
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 a616ff5..1cffeea 100644
--- a/src/main/java/core_service/modules/stats/repo/StatsRepository.java
+++ b/src/main/java/core_service/modules/stats/repo/StatsRepository.java
@@ -10,8 +10,7 @@ import java.util.Map;
@Repository
public class StatsRepository {
- private static final String LISTED =
- "removed_at IS NULL AND hidden_at IS NULL AND user_id NOT IN (SELECT user_id FROM deleted_user)";
+ private static final String NOT_DELETED = "user_id NOT IN (SELECT user_id FROM deleted_user)";
private final JdbcTemplate jdbc;
@@ -22,77 +21,75 @@ public class StatsRepository {
public long countDistinctGames() {
Long v = jdbc.queryForObject(
- "SELECT COUNT(DISTINCT appid) FROM user_game WHERE " + LISTED, Long.class);
+ "SELECT COUNT(DISTINCT appid) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
return v != null ? v : 0L;
}
public long countLibraryEntries() {
Long v = jdbc.queryForObject(
- "SELECT COUNT(*) FROM user_game WHERE " + LISTED, Long.class);
+ "SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
return v != null ? v : 0L;
}
public long countGamesPlayed() {
Long v = jdbc.queryForObject(
- "SELECT COUNT(*) FROM user_game WHERE " + LISTED + " AND playtime_minutes > 0", Long.class);
+ "SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED + " 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 user_game WHERE " + LISTED, Long.class);
+ "SELECT COALESCE(SUM(playtime_minutes), 0) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
return v != null ? v : 0L;
}
public long countUsersWithLibrary() {
Long v = jdbc.queryForObject(
- "SELECT COUNT(DISTINCT user_id) FROM user_game WHERE " + LISTED, Long.class);
+ "SELECT COUNT(DISTINCT user_id) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
return v != null ? v : 0L;
}
public long biggestLibrary() {
Long v = jdbc.queryForObject("""
SELECT COALESCE(MAX(c), 0) FROM (
- SELECT COUNT(*) AS c FROM user_game
+ SELECT COUNT(*) AS c FROM listed_user_game
WHERE %s GROUP BY user_id
) t
- """.formatted(LISTED), Long.class);
+ """.formatted(NOT_DELETED), Long.class);
return v != null ? v : 0L;
}
public long biggestBacklog() {
Long v = jdbc.queryForObject("""
SELECT COALESCE(MAX(c), 0) FROM (
- SELECT COUNT(*) AS c FROM user_game
+ SELECT COUNT(*) AS c FROM listed_user_game
WHERE %s AND playtime_minutes = 0 GROUP BY user_id
) t
- """.formatted(LISTED), Long.class);
+ """.formatted(NOT_DELETED), Long.class);
return v != null ? v : 0L;
}
public List