feat(library): undo auditor merges for 30 days, redirect old keys
Build, Test & Push Core Service / quality-check (push) Successful in 1m14s
Build, Test & Push Core Service / docker (push) Successful in 1m36s

V29 adds game_merge_undo and game_merge_undo_user: every new merge saves
a copy of what it changed. POST /api/library/merges/{mergeId}/undo
(auditors, newest merge first) puts it back; users keep later status
and rating, nothing is paid twice. A nightly job drops copies after 30
days. Old keys follow game_alias to the kept game. Manual games can now
be hidden and shown like Steam games.
This commit is contained in:
lobo committed 2026-10-02 13:39:02 +02:00
1 parent 44af19d32e
commit bb8341dfef
22 files changed
+1163 -50

No files matched your search

+36 -4
View File
@@ -43,7 +43,7 @@ To Be Played is split into three repositories:
weaker hint compares the playtime with IGDB's normal time to beat (at least 90 %), only for games IGDB does not
mark as endless (sandbox, survival, 4X, simulator, MMO). "Not yet" hides a hint per game; a dismissed playtime hint
still lets the ending hint through.
- **Hide games**: a user can hide a Steam game (e.g. a playtest or a tool). It leaves every list and number: library,
- **Hide games**: a user can hide a game, Steam or added by hand (e.g. a playtest or a tool). It leaves every list and number: library,
picker, Pile of Shame, activity, achievement summary, public profile and landing page stats. The sync still
updates its playtime, but no achievements, time to beat, moods or speedrun data are fetched for it. The library
lists hidden games with `hidden=true`.
@@ -140,14 +140,15 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `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 |
| `POST` | `/api/library/merges/{mergeId}/undo` (`mergeId` = the `id` of a `GAME_MERGE` audit row, within 30 days; `204`, `404 MERGE_NOT_FOUND`, `409 UNDO_NOT_AVAILABLE` / `NEWER_MERGE`, `410 UNDO_EXPIRED`) | JWT, AUDITOR |
| `POST`, `DELETE` | `/api/library/games/{appid}/link` (POST `{otherAppid}`, `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED`; DELETE takes the game out of its set, `204`, `404 NOT_LINKED`) | JWT, AUDITOR |
| `GET` | `/api/library/audit-log?action=&page=&size=` (the newest `admin_audit` rows, `PageDto` of `{id, actorUserId, actorName, action, appid, gameName, targetAppid, targetGameName, detail, createdAt}`; `action` is `GAME_MERGE`, `GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`, `GAME_CORRECT` or `GAME_CORRECTION_RESET`; size 20, 40 or 80) | JWT, AUDITOR |
| `GET` | `/api/library/audit-log?action=&page=&size=` (the newest `admin_audit` rows, `PageDto` of `{id, actorUserId, actorName, action, appid, gameName, targetAppid, targetGameName, detail, createdAt, undoable}`; `action` is `GAME_MERGE`, `GAME_MERGE_UNDO`, `GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`, `GAME_CORRECT` or `GAME_CORRECTION_RESET`; size 20, 40 or 80) | JWT, AUDITOR |
| `GET` | `/api/library/link-queue?page=&size=` (`PageDto` of `{a, b, igdb, requests, createdAt}`: both games, most requested first; size 20, 40 or 80) | JWT, AUDITOR |
| `DELETE` | `/api/library/link-queue/{appid}/{otherAppid}` (dismiss; `204`, `404 PAIR_NOT_OPEN`) | JWT, AUDITOR |
| `POST` | `/api/library/games/{appid}/link-requests` (`{otherAppid}`, both in the caller's library; `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED` / `PAIR_DISMISSED`, `429 TOO_MANY_REQUESTS`) | JWT |
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
| `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT |
| `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT |
| `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; Steam and manual games) | JWT |
| `POST` | `/api/library/imports` (`{source: TEXT\|CSV\|SEARCH\|GALAXY\|GROUVEE\|HLTB\|PLAYNITE\|BOOKMARKLET, rows: [{title, platform, playtimeMinutes, status, igdbId}]}`, 1–1000 rows; `202 {id}` in `QUEUED`, `409 IMPORT_RUNNING`, `503 IGDB_OFF`) | JWT |
| `GET` | `/api/library/imports` (`{imports}`, the last 20), `/api/library/imports/{id}` (status, progress counts, `ahead` while it waits in a queue, `attempts`; the rows with their candidates while `READY`) | JWT |
| `POST` | `/api/library/imports/{id}/save` (`{rows: [{lineNo, igdbId, platform, status, playtimeMinutes}]}`; `202`, then poll until `SAVED`; `409 IMPORT_NOT_READY`) | JWT |
@@ -344,7 +345,38 @@ since the sync would bring both back. In one transaction:
- `game_alias` points the old key to the new one (kept flat), so a later add of the old IGDB game lands on the new
key. `admin_audit` gets one `GAME_MERGE` row: who, both keys, both names and how many rows moved or were combined.
There is no undo. The game search shows games already in ToBePlayed first, with their catalog name and image, and
Merges made since V29 can be undone for 30 days (`POST /api/library/merges/{mergeId}/undo`, AUDITOR). The merge saves
a copy in its own transaction: `game_merge_undo` (the deleted shared rows as JSON per table, the link set after the
merge, the flattened aliases) and `game_merge_undo_user` (per user: both `user_game` rows as JSON, the history ids and the
reward ledger ids the merge moved). The copy holds notes, so it goes with the account. A nightly job
(`app.library.merge-undo-purge-cron`) deletes copies older than 30 days; older merges answer `410`.
```mermaid
stateDiagram-v2
[*] --> MERGED: POST /games/{appid}/merge (copy saved)
MERGED --> UNDONE: POST /merges/{id}/undo (30 days, newest first)
MERGED --> FINAL: nightly purge after 30 days
```
The undo, in one transaction (the newest merge of a kept key goes first, else `409 NEWER_MERGE`):
- Shared rows (catalog row, moods, speedrun, time to beat, corrections, broken images, `game_edition`) come back;
`game_alias` of the old key is removed and the aliases the merge flattened point to the old key again.
- A user with only the old row: the row moves back if it is still on the new key, still added by hand (no Steam
takeover) and the user has no old row. It keeps its current status, rating and notes.
- A user with both rows: both rows are back if the kept row still exists. If the user did not change status, rating, notes or
priority of the kept row since the merge (compared with a copy taken right after the merge), each row gets its own old
values. If they did, both rows get the current values. The kept row gets its Steam ownership back when the merge
had replaced it and nothing took the row over since. The old status history goes back to the old key; the log
entries the undo itself causes are removed.
- Ledger rows the merge moved go back to the old key (so does what a moved user earned on the row after the merge), only when the user has the old row
again. Nothing is paid twice. A game the user hid after the merge stays hidden on the restored rows.
- Links: when the kept key's set is still as the merge left it, the old sets come back with their `set_id`s; if
links changed since, they stay and the old key comes back unlinked (the audit detail says "links kept").
- Users who deleted their account are not restored. `admin_audit` gets a `GAME_MERGE_UNDO` row; the `GAME_MERGE` row stays.
`GET /api/library/games/{appid}` follows `game_alias` when the user has no row on the asked key, and answers with the
kept `appid` (the frontend then replaces the URL). After an undo the alias is gone. The game search shows games already in ToBePlayed first, with their catalog name and image, and
games other users added by hand that IGDB did not return; `inCatalog` marks both (only those can be merged).
### Editions of one game