perf(library): add dashboard-games and stored game detail
Build, Test & Push Core Service / quality-check (push) Successful in 2m26s
Build, Test & Push Core Service / docker (push) Successful in 1m32s

Add GET /api/library/dashboard-games for the top and playing tiles
in one call. It has no count query.
GET /api/library/games/{appid}?refresh=false answers from stored
data and sets achievementsStale. It does not wait for Steam.
entryPlatforms reads the view once in a materialized CTE:
planning ~33 ms -> 15 ms.
Test CORS Access-Control-Max-Age 3600 on preflight.

Refs #47
This commit is contained in:
lobo committed 2026-10-12 09:25:49 +02:00
1 parent b1aaec0615
commit 03469f9eb5
14 files changed
+468 -43

No files matched your search

+4 -3
View File
@@ -200,7 +200,7 @@ The token comes from Auth's `tbp_access` cookie or a `Bearer` header, whichever
(`USER < AUDITOR < PLATFORM_ADMIN`). A token without `aud` is refused. The claims are pinned in
`src/test/resources/contract/auth-core-contract.json`, the same file as in Auth; the tests build tokens only from it.
`POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the
frontend origin.
frontend origin; preflight answers carry `Access-Control-Max-Age: 3600` (pinned by `ClientHeaderFilterTest`).
A token for another audience (for example a profile ticket) is rejected on normal endpoints; the profile ticket is
accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` header.
Every error answer of a controller has the body `{code, message}` (a 401 or 403 of the security filters has none); a refused request is `400 VALIDATION_FAILED` with
@@ -236,6 +236,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT |
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
| `GET` | `/api/library/games` (one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual, and `totalPlaytimeMinutes` (all platforms; `playtimeMinutes` stays the counted edition's own); `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored), `/api/library/games/{appid}` | JWT |
| `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/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 |
@@ -263,7 +264,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET`, `DELETE` | `/api/library/psn-account` (`{enabled, linked, linkedAt, expiresAt, reviewImportId}`: the PSN import waiting for review, else null), unlink (`204`, deletes the stored token) | JWT |
| `GET`, `DELETE` | `/api/library/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT |
| `POST` | `/api/library/sync` | JWT |
| `GET` | `/api/library/games/{appid}` (detail; slice 3: `achievements[]` also hold trophies with `kind` `STEAM`\|`PSN`, `setId`, `trophyType`, `group`, `rarity`; new `achievementSets[]` = `{id, kind, platform, name, total, unlocked, perfect, visible, listed}`, Steam first, then PS5 before PS4; `hasAchievements` = any set; the top-level counts stay Steam only; `platforms[]` = `{source, platform, appid, playtimeMinutes, lastPlayedAt, achievementsUnlocked, achievementsTotal, perfect}` one line per platform of the entry, Steam, PS5, PS4, manual, each with its own progress, `appid` = the edition whose detail lists them; `totalPlaytimeMinutes` = their sum) | JWT |
| `GET` | `/api/library/games/{appid}` (detail; `refresh=false` answers from stored data and sets `achievementsStale` when a due Steam refresh was skipped (default `refresh=true` refreshes, as before); slice 3: `achievements[]` also hold trophies with `kind` `STEAM`\|`PSN`, `setId`, `trophyType`, `group`, `rarity`; new `achievementSets[]` = `{id, kind, platform, name, total, unlocked, perfect, visible, listed}`, Steam first, then PS5 before PS4; `hasAchievements` = any set; the top-level counts stay Steam only; `platforms[]` = `{source, platform, appid, playtimeMinutes, lastPlayedAt, achievementsUnlocked, achievementsTotal, perfect}` one line per platform of the entry, Steam, PS5, PS4, manual, each with its own progress, `appid` = the edition whose detail lists them; `totalPlaytimeMinutes` = their sum) | JWT |
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
| `GET` | `/api/library/backlog?limit=&released=` (`{games}` in the user's order; `limit` 1-20, none = all) | JWT |
| `PUT` | `/api/library/backlog` (`{appids}` = the whole order; `204`) | JWT |
@@ -391,7 +392,7 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
### Sync flow
1. `GET /api/library/games`, `/games-count` and `/picker` call `LibrarySyncService.syncIfStale`. It starts a sync
1. `GET /api/library/games`, `/dashboard-games`, `/games-count` and `/picker` call `LibrarySyncService.syncIfStale`. It starts a sync
when the last one is older than 24 hours, older than 15 minutes after a failure, or was for another Steam ID.
`POST /api/library/sync` starts one at once, but at most every 60 seconds (else `429` with `Retry-After`).
2. A nightly job (23:55, Europe/Berlin) syncs every account used in the last 14 days, one after the other. A