feat(follow): add feed link data and game endpoints (#52, #51)
Build, Test & Push Core Service / quality-check (push) Successful in 2m34s
Build, Test & Push Core Service / docker (push) Successful in 1m52s

Feed notes get profileName and appid; follows get lastActivityAt.
New GET /api/library/games/{appid}/preview: catalog data, any game.
New GET /api/public/profiles/library/games/{appid}: one profile game.
This commit is contained in:
lobo committed 2026-10-14 16:23:11 +02:00
1 parent ec66bd5ead
commit d3c9c7558c
24 files changed
+458 -31

No files matched your search

+12 -5
View File
@@ -67,7 +67,12 @@ To Be Played is split into four repositories:
names, status moves collapsed per game, wishlist adds; at most 20 achievements per game, the newest unlocks stand for
the new ones). `GET /api/inbox/{id}/details` shows them only while the reader still follows the subject, the subject
is public, else `404`. Rows of a hidden part or a hidden game are left out. The nightly job at 03:15 deletes notes
older than 90 days (`app.inbox.keep-days`); the list shows the newest 50.
older than 90 days (`app.inbox.keep-days`); the list shows the newest 50. A note also carries `profileName` (the
subject's current name while the profile is public, else null), a detail line its `appid`, and each followed person
in `GET /api/follows` `lastActivityAt` (the newest digest note the caller got about them): the Frontend links the feed
to the game and to the profile view of that game. `GET /api/library/games/{appid}/preview` gives the catalog data of
any game (for "not in your library yet"), `GET /api/public/profiles/library/games/{appid}` one listed game of a public
profile, filtered by the ticket's sections; a hidden, removed or not owned game answers the same `404`.
- **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
@@ -252,6 +257,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/library/dashboard-games?top=10&playing=8` (`{top, playing}`: the Dashboard tiles in one call, listed entries by set playtime, most first; `playing` only status PLAYING; same rows and order as `/games` with `sort=playtimeMinutes,desc` / `status=PLAYING`, no count query; limits clamped to 1..20; calls `syncIfStale` like `/games`) | JWT |
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
| `GET` | `/api/library/games/{appid}/preview` (`{appid, name, imageUrl, genres, timeToBeat}`: catalog data of any game, a merged key answers with the kept key; `404 GAME_NOT_FOUND`) | JWT |
| `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | JWT, AUDITOR |
| `GET` | `/api/library/catalog/{appid}` (what the correction dialog reads: name, image, speedrun, moods, endless, genres, timeToBeat, corrections; `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
| `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
@@ -294,15 +300,16 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` (`canSync`: a Steam ID or a PlayStation link; `sources`: the sources of the user's games) | JWT |
| `GET` | `/api/public/profiles/library` | profile ticket |
| `GET` | `/api/public/profiles/library/stats/games?key=&arg=&page=` (`403 NOT_SHARED`, `400 BAD_ARG`) | profile ticket |
| `GET` | `/api/public/profiles/library/games/{appid}` (`{appid, name, imageUrl, status, playtimeMinutes, achievementsUnlocked, achievementsTotal, completionPercent}`, a field of a hidden section is null; `404 GAME_NOT_FOUND` for a hidden, removed or not owned game or when every field is hidden, `401 INVALID_TICKET`) | 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 |
| `GET` | `/api/follows` (`{following: [{id, displayName}], followers: [{id, displayName}]}`, `id` is the follow row; a follower with a private profile has `displayName` null) | login |
| `GET` | `/api/follows` (`{following: [{id, displayName, lastActivityAt}], followers: [{id, displayName, lastActivityAt}]}`, `id` is the follow row; a follower with a private profile has `displayName` null; `lastActivityAt` = the newest digest note the caller got about that person, null for followers and without a note) | 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, details}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login |
| `GET` | `/api/inbox/{id}/details` (`{results: [{kind, gameName, itemName, fromStatus, toStatus}]}`, `kind` `NEW_GAME`, `ACHIEVEMENT`, `STATUS` or `WISHLIST`; `404` for a note that is not the caller's or not open any more) | login |
| `GET` | `/api/inbox` (`{results: [{id, kind, displayName, profileName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read, details}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login |
| `GET` | `/api/inbox/{id}/details` (`{results: [{appid, kind, gameName, itemName, fromStatus, toStatus}]}`, `kind` `NEW_GAME`, `ACHIEVEMENT`, `STATUS` or `WISHLIST`; `404` for a note that is not the caller's or not open any more) | 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, 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 |
@@ -359,7 +366,7 @@ flowchart LR
LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user; inbox_note (PSN expiry notes)"| 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, inbox_note_detail, public_user, sync_change, user_game_status_change, user_game_achievement, user_psn_trophy, wishlist_game, account_name"| Db
Inbox -->|"inbox_note, inbox_note_detail, follow, public_user, user_game"| Db
Inbox -->|"inbox_note, inbox_note_detail, follow, public_user, user_game, account_name, blocked_user, deleted_user"| Db
Auth -->|"service token<br/>/internal/accounts"| Account
Account -->|"per-user rows, deleted_user, blocked_user, listed_user, public_user, follow, account_name, user_seen_at"| Db
```
@@ -63,6 +63,6 @@ public class FollowController {
}
private static List<FollowsDto.Person> toDto(List<FollowEntry> entries) {
return entries.stream().map(entry -> new FollowsDto.Person(entry.id(), entry.displayName())).toList();
return entries.stream().map(entry -> new FollowsDto.Person(entry.id(), entry.displayName(), entry.lastActivityAt())).toList();
}
}
@@ -1,5 +1,6 @@
package core_service.modules.follow.api.dto;
import java.time.Instant;
import java.util.List;
/**
@@ -8,9 +9,10 @@ import java.util.List;
public record FollowsDto(List<Person> following, List<Person> followers) {
/**
* @param id the follow row, to end it; not a user id
* @param displayName null for a follower with a private profile
* @param id the follow row, to end it; not a user id
* @param displayName null for a follower with a private profile
* @param lastActivityAt following only: when the newest feed message about this person arrived; null without one
*/
public record Person(long id, String displayName) {
public record Person(long id, String displayName, Instant lastActivityAt) {
}
}
@@ -1,8 +1,11 @@
package core_service.modules.follow.domain;
import java.time.Instant;
/**
* @param id the follow row, not a user id
* @param displayName null for a follower with a private profile
* @param id the follow row, not a user id
* @param displayName null for a follower with a private profile
* @param lastActivityAt the newest digest note the caller got about this user; null for followers and without a note
*/
public record FollowEntry(long id, String displayName) {
public record FollowEntry(long id, String displayName, Instant lastActivityAt) {
}
@@ -7,6 +7,7 @@ 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.util.List;
import java.util.Optional;
@@ -20,8 +21,11 @@ public class FollowRepository {
static final int LIST_LIMIT = 500;
private static final RowMapper<FollowEntry> ENTRY = (rs, i) -> new FollowEntry(rs.getLong("id"),
rs.getString("display_name"));
private static final RowMapper<FollowEntry> ENTRY = (rs, i) -> {
Timestamp lastActivityAt = rs.getTimestamp("last_activity_at");
return new FollowEntry(rs.getLong("id"), rs.getString("display_name"),
lastActivityAt == null ? null : lastActivityAt.toInstant());
};
private final NamedParameterJdbcTemplate jdbc;
@@ -91,7 +95,11 @@ public class FollowRepository {
public List<FollowEntry> findFollowing(long userId) {
return jdbc.query("""
select f.id, a.display_name from follow f
select f.id, a.display_name,
(select max(n.created_at) from inbox_note n
where n.user_id = f.user_id and n.subject_user_id = f.followed_user_id
and n.kind = 'DIGEST') as last_activity_at
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
@@ -103,7 +111,8 @@ public class FollowRepository {
*/
public List<FollowEntry> 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
select f.id, case when p.user_id is not null then a.display_name end as display_name,
null::timestamptz as last_activity_at
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
@@ -27,7 +27,7 @@ public class InboxController {
public ResponseEntity<InboxDto> 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(),
.map(note -> new InboxDto.Note(note.id(), note.kind().name(), note.subjectName(), note.profileName(), note.newGames(),
note.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read(), note.details()))
.toList(), inbox.unread()));
}
@@ -36,7 +36,7 @@ public class InboxController {
public ResponseEntity<?> details(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id) {
return inboxService.details(principal.getIdAsLong(), id)
.<ResponseEntity<?>>map(details -> ResponseEntity.ok(new NoteDetailsDto(details.stream()
.map(detail -> new NoteDetailsDto.Detail(detail.kind().name(), detail.gameName(),
.map(detail -> new NoteDetailsDto.Detail(detail.appid(), detail.kind().name(), detail.gameName(),
detail.itemName(), detail.fromStatus(), detail.toStatus()))
.toList())))
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
@@ -11,9 +11,10 @@ public record InboxDto(List<Note> results, int unread) {
/**
* @param kind DIGEST, VISIBILITY, PSN_EXPIRING or PSN_EXPIRED (the PSN kinds have no displayName)
* @param displayName the name of the followed user when the note was written; never a user id
* @param profileName the subject's current name while the profile is public, else null; never an id
* @param details the note can be opened: it has detail rows and the subject is still followed and public
*/
public record Note(long id, String kind, String displayName, int newGames, int achievements, int statusChanges,
public record Note(long id, String kind, String displayName, String profileName, int newGames, int achievements, int statusChanges,
int wishlistAdds, Instant createdAt, boolean read, boolean details) {
}
}
@@ -5,11 +5,12 @@ import java.util.List;
public record NoteDetailsDto(List<Detail> results) {
/**
* @param appid the game key as stored in the note; may be a merged key
* @param kind NEW_GAME, ACHIEVEMENT, STATUS or WISHLIST
* @param itemName ACHIEVEMENT only: the achievement or trophy name
* @param fromStatus STATUS only
* @param toStatus STATUS only
*/
public record Detail(String kind, String gameName, String itemName, String fromStatus, String toStatus) {
public record Detail(long appid, String kind, String gameName, String itemName, String fromStatus, String toStatus) {
}
}
@@ -4,8 +4,9 @@ import java.time.Instant;
/**
* @param subjectName the name of the followed user when the note was written
* @param profileName the current name of the subject while the profile is public, else null
* @param details the note can be opened: it has detail rows and the subject is still followed and public
*/
public record InboxNote(long id, NoteKind kind, String subjectName, int newGames, int achievements,
public record InboxNote(long id, NoteKind kind, String subjectName, String profileName, int newGames, int achievements,
int statusChanges, int wishlistAdds, Instant createdAt, boolean read, boolean details) {
}
@@ -3,5 +3,5 @@ package core_service.modules.inbox.domain;
/**
* One line behind a digest note. Names are snapshots from when the note was written.
*/
public record NoteDetail(NoteDetailKind kind, String gameName, String itemName, String fromStatus, String toStatus) {
public record NoteDetail(long appid, NoteDetailKind kind, String gameName, String itemName, String fromStatus, String toStatus) {
}
@@ -26,6 +26,10 @@ public class InboxRepository {
return jdbc.query("""
select n.id, n.kind, n.subject_name, n.new_games, n.achievements, n.status_changes, n.wishlist_adds,
n.created_at, n.read_at,
(select a.display_name from account_name a join public_user p on p.user_id = a.user_id
where a.user_id = n.subject_user_id
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)) as profile_name,
exists (select 1 from inbox_note_detail d where d.note_id = n.id)
and exists (select 1 from follow f where f.user_id = n.user_id and f.followed_user_id = n.subject_user_id)
and exists (select 1 from public_user p where p.user_id = n.subject_user_id) as details
@@ -33,7 +37,7 @@ public class InboxRepository {
order by n.created_at desc, n.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.getString("subject_name"), rs.getString("profile_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,
rs.getBoolean("details")));
@@ -74,12 +78,12 @@ public class InboxRepository {
*/
public List<NoteDetail> findDetails(long noteId, long subjectUserId) {
return jdbc.query("""
select d.kind, d.game_name, d.item_name, d.from_status, d.to_status from inbox_note_detail d
select d.appid, d.kind, d.game_name, d.item_name, d.from_status, d.to_status from inbox_note_detail d
where d.note_id = :noteId
and not exists (select 1 from user_game ug
where ug.user_id = :subjectUserId and ug.appid = d.appid and ug.hidden_at is not null)
order by d.id""", new MapSqlParameterSource("noteId", noteId).addValue("subjectUserId", subjectUserId),
(rs, i) -> new NoteDetail(NoteDetailKind.valueOf(rs.getString("kind")), rs.getString("game_name"),
(rs, i) -> new NoteDetail(rs.getLong("appid"), NoteDetailKind.valueOf(rs.getString("kind")), rs.getString("game_name"),
rs.getString("item_name"), rs.getString("from_status"), rs.getString("to_status")));
}
@@ -10,6 +10,7 @@ import core_service.modules.library.api.dto.AuditEntryDto;
import core_service.modules.library.api.dto.BrokenImageReport;
import core_service.modules.library.api.dto.CatalogGameDto;
import core_service.modules.library.api.dto.CatalogGameDetailDto;
import core_service.modules.library.api.dto.CatalogPreviewDto;
import core_service.modules.library.api.dto.CatalogHitDto;
import core_service.modules.library.api.dto.CorrectionMarkDto;
import core_service.modules.library.api.dto.GameDetailDto;
@@ -39,6 +40,7 @@ import core_service.modules.library.domain.CatalogGameDetail;
import core_service.modules.library.domain.CatalogHit;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.LinkPair;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.PickSource;
@@ -189,6 +191,14 @@ public class LibraryController {
}
}
@GetMapping("/games/{appid}/preview")
public ResponseEntity<?> getGamePreview(@PathVariable("appid") Long appid) {
return gameDetailService.preview(appid)
.<ResponseEntity<?>>map(p -> ResponseEntity.ok(new CatalogPreviewDto(p.appid(), p.name(), p.imageUrl(),
p.genres(), toDto(p.timeToBeat()))))
.orElseGet(LibraryController::unknownGame);
}
@GetMapping("/games/{appid}")
public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid,
@@ -367,13 +377,16 @@ public class LibraryController {
}
private static CatalogGameDetailDto toDto(CatalogGameDetail d) {
var t = d.timeToBeat();
return new CatalogGameDetailDto(d.appid(), d.name(), d.imageUrl(), d.speedrunUrl(), d.moods(), d.endless(),
d.genres(), t == null ? null : new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(),
t.getNormallyMinutes(), t.getCompletelyMinutes()),
d.genres(), toDto(d.timeToBeat()),
d.corrections().stream().map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList());
}
private static GameDetailDto.TimeToBeatDto toDto(GameTimeToBeat t) {
return t == null ? null : new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), t.getNormallyMinutes(),
t.getCompletelyMinutes());
}
private static CatalogHitDto toDto(CatalogHit hit) {
return new CatalogHitDto(hit.appid(), hit.name(), hit.imageUrl(), hit.platforms());
}
@@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -38,6 +39,17 @@ public class PublicProfileController {
.orElseGet(PublicProfileController::invalidTicket);
}
@GetMapping("/library/games/{appid}")
public ResponseEntity<?> getPublicGame(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket,
@PathVariable("appid") long appid) {
return verify(ticket)
.<ResponseEntity<?>>map(t -> queryService.getPublicGame(t.userId(), t.sections(), appid)
.<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("GAME_NOT_FOUND", "No such game on this profile"))))
.orElseGet(PublicProfileController::invalidTicket);
}
@GetMapping("/library/stats/games")
public ResponseEntity<?> getPublicStatGames(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket,
@RequestParam StatKey key,
@@ -0,0 +1,13 @@
package core_service.modules.library.api.dto;
import java.util.List;
/**
* A game the caller may not own: catalog data only, nothing per user.
*
* @param appid the kept key when the asked key was merged
* @param timeToBeat null when unknown
*/
public record CatalogPreviewDto(long appid, String name, String imageUrl, List<String> genres,
GameDetailDto.TimeToBeatDto timeToBeat) {
}
@@ -0,0 +1,14 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameStatus;
/**
* One game of a public profile. A field is null when a hidden section covers it or the value is unknown.
*
* @param status hidden with the STATUS, PLAYING and FINISHED sections
* @param playtimeMinutes hidden with the SUMMARY section, like the achievements
* @param achievementsUnlocked null when the game has no achievements, Steam hides them or none were fetched
*/
public record PublicGameDto(long appid, String name, String imageUrl, GameStatus status, Integer playtimeMinutes,
Integer achievementsUnlocked, Integer achievementsTotal, Double completionPercent) {
}
@@ -0,0 +1,11 @@
package core_service.modules.library.domain;
import java.util.List;
/**
* Catalog data of one game key, the same for every user.
*
* @param timeToBeat null when unknown
*/
public record CatalogPreview(long appid, String name, String imageUrl, List<String> genres, GameTimeToBeat timeToBeat) {
}
@@ -264,6 +264,13 @@ public class GameMergeRepository {
return targets;
}
/**
* @return the key a merged key now lives under, else the key itself
*/
public long findTarget(long appid) {
return findTargets(List.of(appid)).getOrDefault(appid, appid);
}
/**
* @return the saved copy of a merge, locked until the transaction ends; empty when there is none
*/
@@ -9,6 +9,7 @@ import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.api.dto.PlatformPlayDto;
import core_service.modules.library.domain.AchievementKind;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.CatalogPreview;
import core_service.modules.library.domain.ComingGame;
import core_service.modules.library.domain.Edition;
import core_service.modules.library.domain.GameAchievementSchema;
@@ -125,6 +126,19 @@ public class GameDetailService {
return Optional.of(assemble(refreshed.userGame(), meta, refreshed.hasSchema(), refreshed.achievementsStale()));
}
/**
* Catalog data of a key the caller may not own. No call to Steam or IGDB.
*
* @return empty when the catalog has no such game
*/
public Optional<CatalogPreview> preview(long askedAppid) {
long appid = mergeRepository.findTarget(askedAppid);
return steamGameRepository.findById(appid).map(game -> new CatalogPreview(appid, game.getName(),
game.getImageUrl(),
gameMoodsRepository.findById(appid).map(GameMoods::getGenres).orElse(List.of()),
gameTimeToBeatRepository.findById(appid).orElse(null)));
}
/**
* @param userGame the game after the refresh, re-read when the finish rule changed its status
* @param achievementsStale a due Steam refresh was skipped
@@ -11,6 +11,7 @@ import core_service.modules.library.api.dto.PickedGameDto;
import core_service.modules.library.api.dto.PickerCategoryDto;
import core_service.modules.library.api.dto.PickerDto;
import core_service.modules.library.api.dto.PlatformDto;
import core_service.modules.library.api.dto.PublicGameDto;
import core_service.modules.library.api.dto.PublicLibraryDto;
import core_service.modules.library.api.dto.RecentGameDto;
import core_service.modules.library.api.dto.WishlistDto;
@@ -39,6 +40,7 @@ import core_service.modules.library.repo.FriendPlayRepository;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameCreditRepository;
import core_service.modules.library.repo.GameLinkRepository;
import core_service.modules.library.repo.GameMergeRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.PsnTrophyRepository;
@@ -108,6 +110,7 @@ public class LibraryQueryService {
private final ReleaseDateService releaseDateService;
private final SpeedrunService speedrunService;
private final GameLinkRepository gameLinkRepository;
private final GameMergeRepository mergeRepository;
private final GameCreditRepository gameCreditRepository;
private final FriendPlayRepository friendPlayRepository;
private final PsnTrophyRepository psnTrophyRepository;
@@ -447,6 +450,64 @@ public class LibraryQueryService {
shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
}
/**
* One listed game of a public profile. Loads the achievement rows of the whole library of the owner and keeps
* the one game.
*
* @param sectionNames see {@link ProfileSection#shown}
* @return empty when the owner does not have the game, removed or hid it, or every field is hidden: all the same
* answer, so the caller learns nothing about a hidden game
*/
@Public
public Optional<PublicGameDto> getPublicGame(Long userId, Collection<String> sectionNames, long askedAppid) {
Set<ProfileSection> sections = ProfileSection.shown(sectionNames);
boolean showStatus = sections.contains(ProfileSection.STATUS) || sections.contains(ProfileSection.PLAYING)
|| sections.contains(ProfileSection.FINISHED);
boolean showSummary = sections.contains(ProfileSection.SUMMARY);
if (!showStatus && !showSummary) {
return Optional.empty();
}
return userGameRepository.findById(new UserGameId(userId, mergeRepository.findTarget(askedAppid)))
.filter(ug -> ug.getRemovedAt() == null && ug.getHiddenAt() == null)
.filter(ug -> showsEveryGame(sections) || listedBySection(sections, ug.getStatus()))
.map(userGame -> toPublicGame(userId, userGame, showStatus, showSummary));
}
/** STATUS and SUMMARY already expose the whole library (status lists, totals). */
private static boolean showsEveryGame(Set<ProfileSection> sections) {
return sections.contains(ProfileSection.STATUS) || sections.contains(ProfileSection.SUMMARY);
}
/** Only PLAYING or FINISHED shown: a game is revealed only when a shown section already lists its status. */
private static boolean listedBySection(Set<ProfileSection> sections, GameStatus status) {
return sections.contains(ProfileSection.PLAYING) && status == GameStatus.PLAYING
|| sections.contains(ProfileSection.FINISHED)
&& (status == GameStatus.FINISHED || status == GameStatus.COMPLETED);
}
private PublicGameDto toPublicGame(Long userId, UserGame userGame, boolean showStatus, boolean showSummary) {
long appid = userGame.getAppid();
SteamGame meta = steamGameRepository.findById(appid).orElse(null);
Integer playtime = showSummary
? gameLinkRepository.entryPlaytimes(userId, List.of(appid)).getOrDefault(appid, userGame.getPlaytimeMinutes())
: null;
AchievementProgress achievements = showSummary ? visibleAchievements(userId, appid) : null;
return new PublicGameDto(appid, meta == null ? null : meta.getName(), meta == null ? null : meta.getImageUrl(),
showStatus ? userGame.getStatus() : null, playtime,
achievements == null ? null : achievements.unlockedCapped(),
achievements == null ? null : achievements.total(),
achievements == null ? null : achievements.percent());
}
/**
* @return null when the game has no achievements or Steam hides them
*/
private AchievementProgress visibleAchievements(Long userId, long appid) {
return AchievementProgress.perGame(progress(userId).stream()
.filter(p -> p.appid() == appid && p.hasAchievements() && p.visible()).toList())
.stream().findFirst().orElse(null);
}
/**
* @return null for a hidden section
*/
@@ -62,6 +62,7 @@ class FollowControllerTest extends AbstractIntegrationTest {
@AfterEach
void cleanUp() {
jdbc.update("delete from follow");
jdbc.update("delete from inbox_note");
jdbc.update("delete from follow_digest");
jdbc.update("delete from public_user");
jdbc.update("delete from account_name");
@@ -204,6 +205,38 @@ class FollowControllerTest extends AbstractIntegrationTest {
assertThat(json.toString()).doesNotContain("SecretThree", String.valueOf(LOBO), String.valueOf(SECRET));
Set<String> keys = new HashSet<>();
followers.get(0).propertyNames().forEach(keys::add);
assertThat(keys).containsExactlyInAnyOrder("id", "displayName");
assertThat(keys).containsExactlyInAnyOrder("id", "displayName", "lastActivityAt");
}
private void digestNote(long readerId, long subjectId, String kind, int minutesAgo) {
jdbc.update("insert into inbox_note (user_id, kind, subject_user_id, subject_name, created_at) "
+ "values (?, ?, ?, 'x', now() - make_interval(mins => ?))", readerId, kind, subjectId, minutesAgo);
}
@Test
void following_LastActivityIsTheNewestDigestNoteOfTheCallerAboutThatUser() throws Exception {
jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?), (?, ?), (?, ?)",
VIEWER, LOBO, VIEWER, OTHER, OTHER, LOBO);
digestNote(VIEWER, LOBO, "DIGEST", 60);
digestNote(VIEWER, LOBO, "DIGEST", 10);
digestNote(VIEWER, LOBO, "VISIBILITY", 1);
digestNote(OTHER, LOBO, "DIGEST", 0);
java.time.Instant newest = jdbc.queryForObject("select max(created_at) from inbox_note where user_id = ? and kind = 'DIGEST'",
java.sql.Timestamp.class, VIEWER).toInstant();
JsonNode following = list(VIEWER).get("following");
JsonNode lobo = following.get(0).get("displayName").asString().equals("Lobo") ? following.get(0) : following.get(1);
JsonNode other = lobo == following.get(0) ? following.get(1) : following.get(0);
assertThat(java.time.Instant.parse(lobo.get("lastActivityAt").asString())).isEqualTo(newest);
assertThat(other.get("lastActivityAt").isNull()).isTrue();
}
@Test
void followers_HaveNoLastActivity() throws Exception {
jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", LOBO, VIEWER);
digestNote(VIEWER, LOBO, "DIGEST", 5);
assertThat(list(VIEWER).get("followers").get(0).get("lastActivityAt").isNull()).isTrue();
}
}
@@ -37,6 +37,7 @@ class InboxControllerTest extends AbstractIntegrationTest {
jdbc.update("delete from inbox_note");
jdbc.update("delete from follow where user_id in (?, ?)", READER, OTHER);
jdbc.update("delete from public_user where user_id = 7");
jdbc.update("delete from account_name where user_id = 7");
jdbc.update("delete from user_game where user_id = 7");
jdbc.update("delete from steam_game where appid = 501");
}
@@ -47,6 +48,7 @@ class InboxControllerTest extends AbstractIntegrationTest {
long id = jdbc.queryForObject("select max(id) from inbox_note", Long.class);
jdbc.update("insert into follow (user_id, followed_user_id) values (?, 7)", READER);
jdbc.update("insert into public_user (user_id) values (7)");
jdbc.update("insert into account_name (user_id, display_name) values (7, 'SubjectNow')");
jdbc.update("insert into inbox_note_detail (note_id, kind, appid, game_name, item_name, from_status, to_status) values "
+ "(?, 'NEW_GAME', 501, 'Alpha', null, null, null), "
+ "(?, 'ACHIEVEMENT', 501, 'Alpha', 'Win', null, null), "
@@ -82,6 +84,7 @@ class InboxControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.results[0].kind").value("DIGEST"))
.andExpect(jsonPath("$.results[0].newGames").value(2))
.andExpect(jsonPath("$.results[0].read").value(false))
.andExpect(jsonPath("$.results[0].profileName").doesNotExist())
.andExpect(jsonPath("$.results[1].displayName").value("Older"))
.andExpect(jsonPath("$.unread").value(2))
.andReturn().getResponse().getContentAsString();
@@ -120,12 +123,15 @@ class InboxControllerTest extends AbstractIntegrationTest {
String body = mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk())
.andExpect(jsonPath("$.results.length()").value(3))
.andExpect(jsonPath("$.results[0].kind").value("NEW_GAME"))
.andExpect(jsonPath("$.results[0].appid").value(501))
.andExpect(jsonPath("$.results[2].appid").value(502))
.andExpect(jsonPath("$.results[1].itemName").value("Win"))
.andExpect(jsonPath("$.results[2].fromStatus").value("BACKLOG"))
.andReturn().getResponse().getContentAsString();
assertThat(body).doesNotContain("appid", "subject", "userId");
mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(true));
assertThat(body).doesNotContain("subject", "userId");
mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(true))
.andExpect(jsonPath("$.results[0].profileName").value("SubjectNow"));
}
@Test
@@ -1,5 +1,6 @@
package core_service.modules.inbox.repo;
import core_service.modules.inbox.domain.NoteDetail;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -71,4 +72,36 @@ class InboxRepositoryTest extends AbstractPersistenceTest {
jdbc.update("delete from inbox_note_detail");
assertThat(repository.findNewest(1, 10).get(0).details()).isFalse();
}
@Test
void findNewest_ProfileNameIsTheCurrentNameOfAPublicSubjectOnly() {
jdbc.update("delete from inbox_note");
jdbc.update("delete from account_name where user_id = 2");
jdbc.update("delete from public_user where user_id = 2");
jdbc.update("delete from blocked_user where user_id = 2");
jdbc.update("delete from deleted_user where user_id = 2");
note(1, 1, false);
jdbc.update("insert into account_name (user_id, display_name) values (2, 'Renamed')");
assertThat(repository.findNewest(1, 10).get(0).profileName()).isNull();
jdbc.update("insert into public_user (user_id) values (2)");
assertThat(repository.findNewest(1, 10).get(0).profileName()).isEqualTo("Renamed");
jdbc.update("insert into blocked_user (user_id) values (2)");
assertThat(repository.findNewest(1, 10).get(0).profileName()).isNull();
jdbc.update("delete from blocked_user where user_id = 2");
jdbc.update("insert into deleted_user (user_id) values (2)");
assertThat(repository.findNewest(1, 10).get(0).profileName()).isNull();
jdbc.update("delete from deleted_user where user_id = 2");
}
@Test
void findDetails_CarriesTheGameKey() {
jdbc.update("delete from inbox_note");
note(1, 1, false);
jdbc.update("insert into inbox_note_detail (note_id, kind, appid) values (1, 'NEW_GAME', 5)");
assertThat(repository.findDetails(1, 2)).extracting(NoteDetail::appid).containsExactly(5L);
}
}
@@ -0,0 +1,91 @@
package core_service.modules.library.api;
import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import static org.hamcrest.Matchers.contains;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
class GamePreviewControllerTest extends AbstractIntegrationTest {
private static final long CALLER = 6201L;
private static final long KEPT = -1_960_001L;
private static final long MERGED = -1_960_002L;
private static final long UNKNOWN = -1_960_003L;
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
@AfterEach
void cleanUp() {
jdbc.update("delete from game_alias where from_appid = ?", MERGED);
jdbc.update("delete from game_time_to_beat where appid = ?", KEPT);
jdbc.update("delete from game_mood where appid = ?", KEPT);
jdbc.update("delete from steam_game where appid in (?, ?)", KEPT, MERGED);
}
private void seed() {
jdbc.update("insert into steam_game (appid, name, image_url) values (?, 'Hades', 'http://img/h.png')", KEPT);
jdbc.update("insert into game_mood (appid, moods, genres, checked_at) values (?, '{}', '{Roguelike,Action}', now())", KEPT);
jdbc.update("insert into game_time_to_beat (appid, normally_minutes, completely_minutes, fetched_at) "
+ "values (?, 600, 1200, now())", KEPT);
}
private ResultActions preview(long appid) throws Exception {
return mockMvc.perform(get("/api/library/games/" + appid + "/preview")
.header("Authorization", "Bearer " + ContractTokens.loginToken(CALLER)));
}
@Test
void preview_OfAGameTheCallerDoesNotOwn_HasCatalogDataOnly() throws Exception {
seed();
preview(KEPT).andExpect(status().isOk())
.andExpect(jsonPath("$.appid").value(KEPT))
.andExpect(jsonPath("$.name").value("Hades"))
.andExpect(jsonPath("$.imageUrl").value("http://img/h.png"))
.andExpect(jsonPath("$.genres", contains("Roguelike", "Action")))
.andExpect(jsonPath("$.timeToBeat.normallyMinutes").value(600))
.andExpect(jsonPath("$.timeToBeat.completelyMinutes").value(1200))
.andExpect(jsonPath("$.status").doesNotExist());
}
@Test
void preview_OfAMergedKey_AnswersWithTheKeptKey() throws Exception {
seed();
jdbc.update("insert into steam_game (appid, name) values (?, 'Old Hades')", MERGED);
jdbc.update("insert into game_alias (from_appid, to_appid) values (?, ?)", MERGED, KEPT);
preview(MERGED).andExpect(status().isOk())
.andExpect(jsonPath("$.appid").value(KEPT))
.andExpect(jsonPath("$.name").value("Hades"));
}
@Test
void preview_OfAnUnknownKey_Is404() throws Exception {
preview(UNKNOWN).andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
}
@Test
void preview_WithoutLogin_Is401() throws Exception {
mockMvc.perform(get("/api/library/games/" + KEPT + "/preview")).andExpect(status().isUnauthorized());
}
}
@@ -416,4 +416,95 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
statGames(ticket(USER_ID), "STATUS", "NOPE").andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BAD_ARG"));
}
private ResultActions publicGame(String ticket, long appid) throws Exception {
return mockMvc.perform(get("/api/public/profiles/library/games/" + appid).header("X-Profile-Ticket", ticket));
}
private void achievementsOf220() {
schemaMetaRepository.save(new GameAchievementSchemaMeta(220L, 4, true, Instant.now()));
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 220L, Instant.now(), true, 1, 300));
}
@Test
void publicGame_AllSectionsShown_HasStatusPlaytimeAndAchievements() throws Exception {
achievementsOf220();
publicGame(ticket(USER_ID), 220L).andExpect(status().isOk())
.andExpect(jsonPath("$.appid").value(220))
.andExpect(jsonPath("$.name").value("Half-Life 2"))
.andExpect(jsonPath("$.status").value("PLAYING"))
.andExpect(jsonPath("$.playtimeMinutes").value(300))
.andExpect(jsonPath("$.achievementsUnlocked").value(1))
.andExpect(jsonPath("$.achievementsTotal").value(4))
.andExpect(jsonPath("$.completionPercent").value(25.0))
.andExpect(jsonPath("$.notes").doesNotExist())
.andExpect(jsonPath("$.rating").doesNotExist());
}
@Test
void publicGame_SummaryHidden_HasNoPlaytimeAndNoAchievementsButTheStatus() throws Exception {
achievementsOf220();
publicGame(ticket(USER_ID, List.of(), List.of("STATUS")), 220L).andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PLAYING"))
.andExpect(jsonPath("$.playtimeMinutes").value(nullValue()))
.andExpect(jsonPath("$.achievementsUnlocked").value(nullValue()))
.andExpect(jsonPath("$.achievementsTotal").value(nullValue()))
.andExpect(jsonPath("$.completionPercent").value(nullValue()));
}
@Test
void publicGame_StatusSectionsHidden_HasNoStatusButThePlaytime() throws Exception {
publicGame(ticket(USER_ID, List.of(), List.of("SUMMARY")), 220L).andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(nullValue()))
.andExpect(jsonPath("$.playtimeMinutes").value(300));
}
@Test
void publicGame_OnlyStatsShown_Is404() throws Exception {
publicGame(ticket(USER_ID, List.of(), List.of("STATS")), 220L).andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
}
@Test
void publicGame_HiddenRemovedAndNotOwned_AnswerTheSame404() throws Exception {
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = 700", USER_ID);
String notOwned = publicGame(ticket(USER_ID), 12345L).andExpect(status().isNotFound())
.andReturn().getResponse().getContentAsString();
for (long appid : new long[]{700L, 800L, 99L}) {
String body = publicGame(ticket(USER_ID), appid).andExpect(status().isNotFound())
.andReturn().getResponse().getContentAsString();
org.junit.jupiter.api.Assertions.assertEquals(notOwned, body, "appid " + appid);
}
}
@Test
void publicGame_OnlyPlayingShown_AGameThatIsNotPlayingIs404() throws Exception {
String notOwned = publicGame(ticket(USER_ID, List.of(), List.of("PLAYING")), 12345L)
.andExpect(status().isNotFound()).andReturn().getResponse().getContentAsString();
String completed = publicGame(ticket(USER_ID, List.of(), List.of("PLAYING")), 620L)
.andExpect(status().isNotFound()).andReturn().getResponse().getContentAsString();
assertEquals(notOwned, completed);
}
@Test
void publicGame_SteamGameWithoutSchemaButWithPsnTrophies_Is200() throws Exception {
psnTrophyRepository.saveSet(USER_ID, 220L, new PsnTrophySet("NPWR1_00", "trophy2", "01.00", "Set", null, "PS5",
50, 0, 0, 0, 30, false, Instant.now()));
publicGame(ticket(USER_ID), 220L).andExpect(status().isOk())
.andExpect(jsonPath("$.achievementsUnlocked").value(30))
.andExpect(jsonPath("$.achievementsTotal").value(50));
}
@Test
void publicGame_WithoutOrWithABadTicket_Is401() throws Exception {
mockMvc.perform(get("/api/public/profiles/library/games/220")).andExpect(status().isUnauthorized());
publicGame("garbage", 220L).andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("INVALID_TICKET"));
}
}