Compare commits

...
10 Commits
Author SHA1 Message Date
lobo e11fae8e31 feat(follow): add player search with rate limit (#57)
Build, Test & Push Core Service / quality-check (push) Successful in 2m19s
Build, Test & Push Core Service / docker (push) Successful in 1m34s
Find public profiles by name: GET /api/follows/players?q=.
Queries under 2 characters find nothing; more than 30 searches
per minute get 429. LIKE escaping is shared with the library search.
2026-10-21 14:54:48 +02:00
lobo c42526bc16 feat(library): sort by name, last played and time to beat (#56)
Sort by name ignores upper and lower case.
Games never played, or without a time to beat, come last.
2026-10-21 11:09:44 +02:00
lobo 4fbf572f32 perf(datasource): reuse query plans on core connections (#49)
Build, Test & Push Core Service / quality-check (push) Successful in 2m11s
Build, Test & Push Core Service / docker (push) Successful in 1m29s
Planning was 94% of Dashboard DB time, and prod never reused a plan.
Force generic plans and prepare on first use. Burst: 443 -> 229 ms.
2026-10-20 11:41:31 +02:00
lobo 6f2d7b4885 fix(library): book psn play on its last played day (#53)
Build, Test & Push Core Service / quality-check (push) Successful in 2m35s
Build, Test & Push Core Service / docker (push) Successful in 1m29s
A newly linked PSN title booked its whole history on the sync day.
Now only new play counts, on its last played day. A day is capped
at 24 h. V49 deletes the wrong old day rows.
2026-10-20 09:06:53 +02:00
lobo d3c9c7558c 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.
2026-10-14 16:23:11 +02:00
lobo ec66bd5ead chore: remove unused dev compose and prometheus dependency
Build, Test & Push Core Service / quality-check (push) Successful in 3m9s
Build, Test & Push Core Service / docker (push) Successful in 2m4s
Prometheus is not in use. Only /actuator/health is exposed.
2026-10-14 15:22:20 +02:00
lobo 54946ef0eb docs(readme): sync features, endpoints and config with code
Build, Test & Push Core Service / quality-check (push) Successful in 2m38s
Build, Test & Push Core Service / docker (push) Successful in 2m3s
The readme had drifted from the code. Update sync rules, thread pools,
API tables, env vars and the CI setup.
2026-10-14 14:51:40 +02:00
lobo dba4fedfa1 test(wiremock): add 30-game library and 6 achievement sets
Build, Test & Push Core Service / quality-check (push) Successful in 2m32s
Build, Test & Push Core Service / docker (push) Successful in 1m32s
Local WireMock library grows from 8 to 30 games.
Six games get schema, achievement and percentage stubs.
LocalMocksParseTest checks each stub appid is an owned game.
2026-10-13 12:03:56 +02:00
lobo cdfbb4a863 fix(follow): keep sync status rows for the sync digest
A hand-only digest used to move the cursor past all status rows.
It now stops at the last by_user change, so the sync digest still
gets its status detail lines.
2026-10-13 11:15:10 +02:00
lobo c6bfea1976 feat(inbox): add details for digest notes
Digest notes now keep the lines behind their counts in
inbox_note_detail (V48). GET /api/inbox/{id}/details shows them
while the reader still follows the subject and it is public.
2026-10-13 09:17:41 +02:00
102 changed files with 3387 additions and 191 deletions

No files matched your search

-22
View File
@@ -1,22 +0,0 @@
# docker-compose.dev.yml
services:
# ─────────────────────────────────────────────
# LOCAL DATABASE FOR DEVELOPMENT
# ─────────────────────────────────────────────
tbp-db-local:
image: postgres:17-alpine
container_name: tbp_db_local
restart: unless-stopped
ports:
# Map the container port 5432 to your local machine port 5432
- "5432:5432"
environment:
- POSTGRES_DB=tbp_core
- POSTGRES_USER=tbp_user
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
# Use a local volume to persist data across container restarts
- tbp_local_db_data:/var/lib/postgresql/data
volumes:
tbp_local_db_data:
@@ -0,0 +1,30 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_B1",
"percent": 81.0
},
{
"name": "ACH_B2",
"percent": 57.3
},
{
"name": "ACH_B3",
"percent": 39.8
},
{
"name": "ACH_B4",
"percent": 26.5
},
{
"name": "ACH_B5",
"percent": 23.6
},
{
"name": "ACH_B6",
"percent": 18.2
}
]
}
}
@@ -0,0 +1,38 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_A1",
"percent": 88.2
},
{
"name": "ACH_A2",
"percent": 52.7
},
{
"name": "ACH_A3",
"percent": 74.1
},
{
"name": "ACH_A4",
"percent": 31.4
},
{
"name": "ACH_A5",
"percent": 44.9
},
{
"name": "ACH_A6",
"percent": 36.0
},
{
"name": "ACH_A7",
"percent": 28.3
},
{
"name": "ACH_A8",
"percent": 4.6
}
]
}
}
@@ -0,0 +1,30 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_F1",
"percent": 91.2
},
{
"name": "ACH_F2",
"percent": 64.8
},
{
"name": "ACH_F3",
"percent": 48.5
},
{
"name": "ACH_F4",
"percent": 37.1
},
{
"name": "ACH_F5",
"percent": 22.4
},
{
"name": "ACH_F6",
"percent": 11.0
}
]
}
}
@@ -0,0 +1,30 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_E1",
"percent": 83.4
},
{
"name": "ACH_E2",
"percent": 62.0
},
{
"name": "ACH_E3",
"percent": 44.3
},
{
"name": "ACH_E4",
"percent": 31.9
},
{
"name": "ACH_E5",
"percent": 27.5
},
{
"name": "ACH_E6",
"percent": 9.7
}
]
}
}
@@ -0,0 +1,30 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_C1",
"percent": 78.5
},
{
"name": "ACH_C2",
"percent": 47.2
},
{
"name": "ACH_C3",
"percent": 33.6
},
{
"name": "ACH_C4",
"percent": 35.4
},
{
"name": "ACH_C5",
"percent": 41.0
},
{
"name": "ACH_C6",
"percent": 12.9
}
]
}
}
@@ -0,0 +1,30 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_D1",
"percent": 70.3
},
{
"name": "ACH_D2",
"percent": 25.8
},
{
"name": "ACH_D3",
"percent": 30.2
},
{
"name": "ACH_D4",
"percent": 15.6
},
{
"name": "ACH_D5",
"percent": 12.1
},
{
"name": "ACH_D6",
"percent": 8.4
}
]
}
}
+205 -7
View File
@@ -1,6 +1,6 @@
{ {
"response": { "response": {
"game_count": 8, "game_count": 30,
"games": [ "games": [
{ {
"appid": 620, "appid": 620,
@@ -8,7 +8,8 @@
"playtime_forever": 1450, "playtime_forever": 1450,
"playtime_2weeks": null, "playtime_2weeks": null,
"img_icon_url": "", "img_icon_url": "",
"has_community_visible_stats": true "has_community_visible_stats": true,
"rtime_last_played": 1787300000
}, },
{ {
"appid": 1145360, "appid": 1145360,
@@ -16,7 +17,8 @@
"playtime_forever": 3200, "playtime_forever": 3200,
"playtime_2weeks": 240, "playtime_2weeks": 240,
"img_icon_url": "", "img_icon_url": "",
"has_community_visible_stats": true "has_community_visible_stats": true,
"rtime_last_played": 1790500000
}, },
{ {
"appid": 413150, "appid": 413150,
@@ -24,7 +26,8 @@
"playtime_forever": 5400, "playtime_forever": 5400,
"playtime_2weeks": null, "playtime_2weeks": null,
"img_icon_url": "", "img_icon_url": "",
"has_community_visible_stats": true "has_community_visible_stats": true,
"rtime_last_played": 1788900000
}, },
{ {
"appid": 427520, "appid": 427520,
@@ -32,7 +35,8 @@
"playtime_forever": 12000, "playtime_forever": 12000,
"playtime_2weeks": 600, "playtime_2weeks": 600,
"img_icon_url": "", "img_icon_url": "",
"has_community_visible_stats": true "has_community_visible_stats": true,
"rtime_last_played": 1790900000
}, },
{ {
"appid": 504230, "appid": 504230,
@@ -40,7 +44,8 @@
"playtime_forever": 800, "playtime_forever": 800,
"playtime_2weeks": null, "playtime_2weeks": null,
"img_icon_url": "", "img_icon_url": "",
"has_community_visible_stats": true "has_community_visible_stats": true,
"rtime_last_played": 1789200000
}, },
{ {
"appid": 367520, "appid": 367520,
@@ -56,7 +61,8 @@
"playtime_forever": 780, "playtime_forever": 780,
"playtime_2weeks": null, "playtime_2weeks": null,
"img_icon_url": "", "img_icon_url": "",
"has_community_visible_stats": true "has_community_visible_stats": true,
"rtime_last_played": 1786900000
}, },
{ {
"appid": 892970, "appid": 892970,
@@ -64,6 +70,198 @@
"playtime_forever": 90, "playtime_forever": 90,
"playtime_2weeks": 90, "playtime_2weeks": 90,
"img_icon_url": "", "img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790700000
},
{
"appid": 1245620,
"name": "ELDEN RING",
"playtime_forever": 6100,
"playtime_2weeks": 900,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790000000
},
{
"appid": 1091500,
"name": "Cyberpunk 2077",
"playtime_forever": 4300,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1787900000
},
{
"appid": 292030,
"name": "The Witcher 3: Wild Hunt",
"playtime_forever": 5200,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1789800000
},
{
"appid": 1086940,
"name": "Baldur's Gate 3",
"playtime_forever": 2800,
"playtime_2weeks": 300,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790150000
},
{
"appid": 730,
"name": "Counter-Strike 2",
"playtime_forever": 1900,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1788600000
},
{
"appid": 570,
"name": "Dota 2",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 1174180,
"name": "Red Dead Redemption 2",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 105600,
"name": "Terraria",
"playtime_forever": 2600,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1789300000
},
{
"appid": 294100,
"name": "RimWorld",
"playtime_forever": 3900,
"playtime_2weeks": 120,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790300000
},
{
"appid": 632470,
"name": "Disco Elysium - The Final Cut",
"playtime_forever": 1500,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1788100000
},
{
"appid": 548430,
"name": "Deep Rock Galactic",
"playtime_forever": 700,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790000000
},
{
"appid": 646570,
"name": "Slay the Spire",
"playtime_forever": 2200,
"playtime_2weeks": 60,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790450000
},
{
"appid": 2379780,
"name": "Balatro",
"playtime_forever": 1100,
"playtime_2weeks": 420,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790600000
},
{
"appid": 1794680,
"name": "Vampire Survivors",
"playtime_forever": 450,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790700000
},
{
"appid": 268910,
"name": "Cuphead",
"playtime_forever": 900,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1787600000
},
{
"appid": 814380,
"name": "Sekiro: Shadows Die Twice",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 374320,
"name": "DARK SOULS III",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 1057090,
"name": "Ori and the Will of the Wisps",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 275850,
"name": "No Man's Sky",
"playtime_forever": 120,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790200000
},
{
"appid": 526870,
"name": "Satisfactory",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 1332010,
"name": "Stray",
"playtime_forever": 330,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1789000000
},
{
"appid": 108600,
"name": "Project Zomboid",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true "has_community_visible_stats": true
} }
] ]
@@ -0,0 +1,39 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Cyberpunk 2077",
"success": true,
"achievements": [
{
"apiname": "ACH_B1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_B2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_B3",
"achieved": 1,
"unlocktime": 1786518400
},
{
"apiname": "ACH_B4",
"achieved": 1,
"unlocktime": 1786777600
},
{
"apiname": "ACH_B5",
"achieved": 1,
"unlocktime": 1787036800
},
{
"apiname": "ACH_B6",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,49 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "ELDEN RING",
"success": true,
"achievements": [
{
"apiname": "ACH_A1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_A2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_A3",
"achieved": 1,
"unlocktime": 1786518400
},
{
"apiname": "ACH_A4",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_A5",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_A6",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_A7",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_A8",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,39 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Stray",
"success": true,
"achievements": [
{
"apiname": "ACH_F1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_F2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_F3",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_F4",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_F5",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_F6",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,39 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Cuphead",
"success": true,
"achievements": [
{
"apiname": "ACH_E1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_E2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_E3",
"achieved": 1,
"unlocktime": 1786518400
},
{
"apiname": "ACH_E4",
"achieved": 1,
"unlocktime": 1786777600
},
{
"apiname": "ACH_E5",
"achieved": 1,
"unlocktime": 1787036800
},
{
"apiname": "ACH_E6",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,39 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Disco Elysium - The Final Cut",
"success": true,
"achievements": [
{
"apiname": "ACH_C1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_C2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_C3",
"achieved": 1,
"unlocktime": 1786518400
},
{
"apiname": "ACH_C4",
"achieved": 1,
"unlocktime": 1786777600
},
{
"apiname": "ACH_C5",
"achieved": 1,
"unlocktime": 1787036800
},
{
"apiname": "ACH_C6",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,39 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Slay the Spire",
"success": true,
"achievements": [
{
"apiname": "ACH_D1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_D2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_D3",
"achieved": 1,
"unlocktime": 1786518400
},
{
"apiname": "ACH_D4",
"achieved": 1,
"unlocktime": 1786777600
},
{
"apiname": "ACH_D5",
"achieved": 1,
"unlocktime": 1787036800
},
{
"apiname": "ACH_D6",
"achieved": 1,
"unlocktime": 1787296000
}
]
}
}
@@ -0,0 +1,58 @@
{
"game": {
"gameName": "Cyberpunk 2077",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_B1",
"displayName": "Street Kid",
"description": "Get through the opening job.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
},
{
"name": "ACH_B2",
"displayName": "Chrome Fan",
"description": "Install five implants.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
},
{
"name": "ACH_B3",
"displayName": "Fast Driver",
"description": "Win a street race.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
},
{
"name": "ACH_B4",
"displayName": "Netrunner",
"description": "Hack ten enemies in one fight.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
},
{
"name": "ACH_B5",
"displayName": "Final Run",
"description": "Complete the main story.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
},
{
"name": "ACH_B6",
"displayName": "Rich Merc",
"description": "Hold a big pile of eddies.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,74 @@
{
"game": {
"gameName": "ELDEN RING",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_A1",
"displayName": "Rune Bearer",
"description": "Pick up your first rune from the grace.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A2",
"displayName": "Margit Down",
"description": "Defeat the warden of the gate.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A3",
"displayName": "Roundtable Guest",
"description": "Visit the hold between worlds.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A4",
"displayName": "Smith Friend",
"description": "Upgrade a weapon to a high level.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A5",
"displayName": "Mounted Hunter",
"description": "Slay a foe from horseback.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A6",
"displayName": "Great Rune",
"description": "Restore a great rune.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A7",
"displayName": "Deep Delver",
"description": "Explore a deep underground city.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A8",
"displayName": "Lord of Lands",
"description": "Gather every legendary talisman.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,58 @@
{
"game": {
"gameName": "Stray",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_F1",
"displayName": "Cat Nap",
"description": "Sleep in a sunny spot.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
},
{
"name": "ACH_F2",
"displayName": "Rooftop Walker",
"description": "Cross the roofs of the old slum.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
},
{
"name": "ACH_F3",
"displayName": "Robot Friend",
"description": "Make a friend of a small robot.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
},
{
"name": "ACH_F4",
"displayName": "Meow Choir",
"description": "Meow in front of ten robots.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
},
{
"name": "ACH_F5",
"displayName": "Fish Lover",
"description": "Find a cold bowl of fish.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
},
{
"name": "ACH_F6",
"displayName": "Secret Paw",
"description": "Open every hidden door.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,58 @@
{
"game": {
"gameName": "Cuphead",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_E1",
"displayName": "Contract Signed",
"description": "Take the deal at the casino.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
},
{
"name": "ACH_E2",
"displayName": "Run and Gun",
"description": "Pass a platform stage.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
},
{
"name": "ACH_E3",
"displayName": "Boss Rush",
"description": "Defeat five bosses.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
},
{
"name": "ACH_E4",
"displayName": "Devil Beaten",
"description": "Complete the main story.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
},
{
"name": "ACH_E5",
"displayName": "Coin Collector",
"description": "Find every coin of a world.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
},
{
"name": "ACH_E6",
"displayName": "Perfect Parry",
"description": "Parry twelve pink shots in a row.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,58 @@
{
"game": {
"gameName": "Disco Elysium - The Final Cut",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_C1",
"displayName": "Fresh Case",
"description": "Wake up in the hostel room.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
},
{
"name": "ACH_C2",
"displayName": "Good Listener",
"description": "Talk to every skill voice.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
},
{
"name": "ACH_C3",
"displayName": "Thought Cabinet",
"description": "Fill all slots of the cabinet.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
},
{
"name": "ACH_C4",
"displayName": "Case Closed",
"description": "Finish the main story.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
},
{
"name": "ACH_C5",
"displayName": "Best Friend",
"description": "Win the trust of your partner.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
},
{
"name": "ACH_C6",
"displayName": "Sweet Tunes",
"description": "Hear every song on the radio.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,58 @@
{
"game": {
"gameName": "Slay the Spire",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_D1",
"displayName": "Spire Climber",
"description": "Reach the top of the first act.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
},
{
"name": "ACH_D2",
"displayName": "Card Hoarder",
"description": "Own forty cards in one run.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
},
{
"name": "ACH_D3",
"displayName": "Relic Fan",
"description": "Hold ten relics in one run.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
},
{
"name": "ACH_D4",
"displayName": "Silent Hunter",
"description": "Win a run as the second hero.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
},
{
"name": "ACH_D5",
"displayName": "Cold Heart",
"description": "Win a run as the third hero.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
},
{
"name": "ACH_D6",
"displayName": "Pure Deck",
"description": "Win a run with a tiny deck.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "1091500"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-1091500.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "1245620"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-1245620.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "1332010"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-1332010.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "268910"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-268910.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "632470"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-632470.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "646570"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-646570.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "1091500"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-1091500.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "1245620"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-1245620.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "1332010"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-1332010.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "268910"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-268910.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "632470"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-632470.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "646570"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-646570.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "1091500"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-1091500.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "1245620"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-1245620.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "1332010"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-1332010.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "268910"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-268910.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "632470"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-632470.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "646570"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-646570.json"
}
}
-5
View File
@@ -89,11 +89,6 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId> <artifactId>spring-boot-starter-actuator</artifactId>
</dependency> </dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<scope>runtime</scope>
</dependency>
<!-- testing --> <!-- testing -->
<dependency> <dependency>
+95 -47
View File
@@ -1,22 +1,27 @@
# To Be Played – Core Service # To Be Played – Core Service
Business service of **To Be Played**, a Steam backlog tracker. It syncs the user's Steam library, stores the backlog Business service of **To Be Played**, a backlog tracker for your Steam and PlayStation libraries, plus games added
(status, rating, notes, priority), tracks playtime per day, syncs the wishlist and adds time-to-beat data from IGDB. by hand or imported from GOG Galaxy, Playnite, Grouvee and HowLongToBeat files. It syncs the user's Steam library and
the linked PlayStation account, stores the backlog (status, rating, notes, priority), tracks playtime per day, syncs
the wishlist and adds time-to-beat data from IGDB.
To Be Played is split into three repositories: To Be Played is split into four repositories:
| Repository | Role | | Repository | Role |
|---|---| |---|---|
| ToBePlayed-Auth | Users, login, settings, signs JWTs | | ToBePlayed-Auth | Users, login, settings, signs JWTs |
| **ToBePlayed-Core** (this one) | Steam library, backlog, wishlist, stats; verifies JWTs | | **ToBePlayed-Core** (this one) | Steam library, backlog, wishlist, stats; verifies JWTs |
| ToBePlayed-Frontend | React single-page app | | ToBePlayed-Frontend | React single-page app |
| ToBePlayed-Deployment | Docker Compose stack, env template, ROADMAP.md (history), plans |
## Features ## Features
- **Steam sync**: owned games, playtime and achievements through the Steam Web API. Manual sync plus a nightly - **Steam sync**: owned games, playtime and achievements through the Steam Web API. Manual sync plus a nightly
sync. Resilience4j rate-limits every Steam call and tries the owned games up to three times (never again on a 4xx; sync. Resilience4j rate-limits every Steam call (2 per second) and retries only where a repeat is safe: owned games,
the wishlist and the calls a page waits for once); Caffeine caches game counts and player summaries. A sync commits its claim (`RUNNING`, with achievement schema and player achievements up to 3 times (`steamApi`, never on a 4xx); player summary and wishlist
`sync_started_at`), its writes and its finish apart, and calls Steam and IGDB outside any transaction: a second once with an empty fallback (`steamApiOnce`); store, release and global-percentage calls not at all. Caffeine caches
player summaries (`steamPlayerSummary`), IGDB searches and the landing numbers, not game counts. A sync commits its
claim (`RUNNING`, with `sync_started_at`), its writes and its finish apart, and calls Steam and IGDB outside any transaction: a second
sync sees `RUNNING` and skips, a failed one stores `FAILED`. A run older than `app.library.running-stale-minutes` sync sees `RUNNING` and skips, a failed one stores `FAILED`. A run older than `app.library.running-stale-minutes`
can be taken over; its own finish then changes nothing. can be taken over; its own finish then changes nothing.
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority, - **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
@@ -40,12 +45,35 @@ To Be Played is split into three repositories:
`SERIES`, `studioName` for `STUDIO`, `friendName`, `friendFinished` and `friendPlaytimeMinutes` for `FRIENDS` or `firstSeenAt` for `LONGEST`). `SERIES`, `studioName` for `STUDIO`, `friendName`, `friendFinished` and `friendPlaytimeMinutes` for `FRIENDS` or `firstSeenAt` for `LONGEST`).
Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`, Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`,
`CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from speedrun.com. `CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from speedrun.com.
```mermaid
flowchart LR
NEXT_UP --> Rows
SERIES --> Rows
SIMILAR --> Rows
STUDIO --> Rows
FRIENDS --> Rows
LONGEST --> Rows
RANDOM --> Rows
Rows["up to 3 games per row (ROW_SIZE), empty rows left out"] --> Filters["budget + exclude: every row; mood: RANDOM only"]
Filters --> Out["games + reason"]
```
- **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most - **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most
500, never an id in an answer) and gets one inbox note per followed user and batch (`/api/inbox`): a sync with its 500, never an id in an answer; `GET /api/follows/players?q=` finds public profiles by name, rate-limited) and gets
achievement run, or the hand changes of a quiet 10 minutes. A note counts new games, achievement unlocks, status one inbox note per followed user and batch (`/api/inbox`): a sync with its achievement run, or the hand changes of a
changes and Steam wishlist adds, and leaves out what the followed user's profile hides (`public_user.sections`). quiet 10 minutes. A note counts new games, achievement unlocks, status changes and Steam wishlist adds, and leaves
out what the followed user's profile hides (`public_user.sections`).
Follows end for good, with a note, when the followed user goes private, and without a note when the user is blocked or Follows end for good, with a note, when the followed user goes private, and without a note when the user is blocked or
deleted. The nightly job at 03:15 deletes notes older than 90 days (`app.inbox.keep-days`); the list shows the newest 50. deleted. Each note keeps one copy of the lines behind its counts (`inbox_note_detail`: games, achievement or trophy
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. 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, - **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 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 goes to the bottom, a game leaving it loses its row. V34 seeded the first order (priority HIGH, MEDIUM, rest, then
@@ -57,16 +85,6 @@ To Be Played is split into three repositories:
sum). It keeps its status and shows `coming` / `comingOn`. Date: IGDB `first_release_date`, else Steam's store sum). It keeps its status and shows `coming` / `comingOn`. Date: IGDB `first_release_date`, else Steam's store
(`GetItems` with `include_release`), else unknown (= released). View `coming_game`, table `game_release`; a (`GetItems` with `include_release`), else unknown (= released). View `coming_game`, table `game_release`; a
year-only IGDB date stays a placeholder until the real date comes (coming rows refresh daily). year-only IGDB date stays a placeholder until the real date comes (coming rows refresh daily).
```mermaid
flowchart LR
A["anchor: random of top 5 played"] --> S[similar]
S --> M["PickMix: rotate 4 sources, dedupe, random fallback"]
L[longest waiting] --> M
R[random] --> M
N["next up: random of top 5 in order"] --> M
M --> O["games + reason"]
```
- **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (case, - **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (case,
punctuation, ™ and roman numerals ignored) and a release year at most one year off, when IGDB knows it, with at punctuation, ™ and roman numerals ignored) and a release year at most one year off, when IGDB knows it, with at
least one verified run. An edition that finds nothing tries its IGDB parent game with the parent's year, else its least one verified run. An edition that finds nothing tries its IGDB parent game with the parent's year, else its
@@ -81,8 +99,8 @@ To Be Played is split into three repositories:
of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never
overwrites stored data. Summary: unlocked total, perfect games, average completion over started games, the 5 overwrites stored data. Summary: unlocked total, perfect games, average completion over started games, the 5
games closest to 100 %. games closest to 100 %.
- **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks" - **Playtime history**: one row per game and day (a day counts at most 24 h in the stats and the chart), for the
dashboard banner. activity view and the "most played in the last two weeks" dashboard banner.
- **Finished from achievements or playtime**: `EndingAchievement` finds the one achievement of a game that means - **Finished from achievements or playtime**: `EndingAchievement` finds the one achievement of a game that means
"story end" (English text rule, Steam's global unlock rate between 1 and 80 %, the easiest variant wins, more than "story end" (English text rule, Steam's global unlock rate between 1 and 80 %, the easiest variant wins, more than
3 candidates = a game without one end). When the user unlocks it, never chose the status and never answered 3 candidates = a game without one end). When the user unlocks it, never chose the status and never answered
@@ -174,8 +192,8 @@ flowchart LR
header picture (`IStoreBrowseService/GetItems` with assets) goes to `steam_game.image_url`. When a few achievement header picture (`IStoreBrowseService/GetItems` with assets) goes to `steam_game.image_url`. When a few achievement
icons do not load, the schema comes fresh from Steam; an empty answer keeps the stored one. A report that cannot be repaired is tried again on the next report, at most 3 times, icons do not load, the schema comes fresh from Steam; an empty answer keeps the stored one. A report that cannot be repaired is tried again on the next report, at most 3 times,
then after 30 days. then after 30 days.
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the - **Time to beat**: IGDB's `hastily`, `normally` and `completely` minutes (`game_time_to_beat`, V6). The Frontend labels
feature is off and the rest works. them story / extras / 100 %. Optional: without credentials the feature is off and the rest works.
- **PlayStation link** (optional, unofficial API): the user pastes an NPSSO once, Core keeps only the encrypted refresh - **PlayStation link** (optional, unofficial API): the user pastes an NPSSO once, Core keeps only the encrypted refresh
token (about 10 days). Every library sync then adds the played PS4/PS5 titles that match IGDB exactly, keeps the unsure token (about 10 days). Every library sync then adds the played PS4/PS5 titles that match IGDB exactly, keeps the unsure
ones for the import review, and updates playtime and last played date. The same sync reads the PS4/PS5 **trophy ones for the import review, and updates playtime and last played date. The same sync reads the PS4/PS5 **trophy
@@ -233,12 +251,14 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name, unlock `progress` per track, `earlySince` for the rules text) | JWT | | `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name, unlock `progress` per track, `earlySince` for the rules text) | JWT |
| `GET` | `/api/rewards/expected?appids=` (`{appid: points}`, the full finish value, 0 when paid; read-only, no settle; `400 TOO_MANY` over 200 appids) | JWT | | `GET` | `/api/rewards/expected?appids=` (`{appid: points}`, the full finish value, 0 when paid; read-only, no settle; `400 TOO_MANY` over 200 appids) | JWT |
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT | | `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT |
| `GET` | `/api/rewards/games/{appid}` (settles, then the paid rows of the game and its set, `bookedNow`, one reason per missing kind; `404 GAME_NOT_FOUND`) | JWT |
| `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 | | `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 | | `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/games` (`sort` = `playtimeMinutes`, `firstSeenAt`, `lastSeenAt`, `rating`, `priority`, `name`, `lastPlayedAt` or `timeToBeat` plus `,asc` or `,desc`; a game never played or without a time to beat comes last; 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) | 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 | | `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 | | `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 | | `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?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 | | `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/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
@@ -255,6 +275,12 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT | | `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT |
| `GET` | `/api/library/removed-games` (`{results: [{appid, name, imageUrl, source, platform, removedAt}]}`: the caller's removed `MANUAL` and `PSN` games, newest first) | JWT | | `GET` | `/api/library/removed-games` (`{results: [{appid, name, imageUrl, source, platform, removedAt}]}`: the caller's removed `MANUAL` and `PSN` games, newest first) | JWT |
| `POST` | `/api/library/games/{appid}/restore` (a removed game comes back with its old data; `204`, also when it is already back; `404 GAME_NOT_FOUND`, `409 STEAM_GAME`) | JWT | | `POST` | `/api/library/games/{appid}/restore` (a removed game comes back with its old data; `204`, also when it is already back; `404 GAME_NOT_FOUND`, `409 STEAM_GAME`) | JWT |
| `GET` | `/api/library/game-search?q=` (IGDB search for "add a game", `q` 2-100 characters; `{available, results: [{appid, inLibrary, ...}]}`; `502 IGDB_FAILED`) | JWT |
| `POST` | `/api/library/games` (`{igdbId, platform, status, playtimeMinutes?}`; `201 {appid}`, `400 UNKNOWN_GAME`, `409 ALREADY_IN_LIBRARY`, `503 IGDB_OFF`) | JWT |
| `PUT` | `/api/library/games/{appid}/manual` (`{platform, playtimeMinutes}`, MANUAL and PSN rows, a PSN row keeps its playtime; `204`, `404 GAME_NOT_FOUND`, `409 STEAM_GAME`) | JWT |
| `DELETE` | `/api/library/games/{appid}` (soft remove, same answers) | JWT |
| `GET` | `/api/library/sync-changes` (`{syncedAt, startedBy, seen, importedGames, changes[]}` of the last sync; `204` when none) | JWT |
| `PUT` | `/api/library/sync-changes/seen` (`{syncedAt}`; idempotent; `204`) | JWT |
| `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; Steam and manual games) | 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 | | `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 | | `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 |
@@ -264,7 +290,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/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 | | `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 | | `POST` | `/api/library/sync` | 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 | | `GET` | `/api/library/games/{appid}` (detail; `refresh=false` answers from stored data and sets `achievementsStale` when a due Steam refresh was skipped; `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 | | `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 | | `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 | | `PUT` | `/api/library/backlog` (`{appids}` = the whole order; `204`) | JWT |
@@ -275,14 +301,17 @@ 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/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` | 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/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/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/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/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/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 | | `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 | | `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}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login | | `GET` | `/api/follows/players?q=` (`{results: [{displayName}]}`: public profiles whose name contains `q`, case-insensitive, not blocked or deleted, at most 20, names that start with `q` first, the caller's own name included; `q` under 2 characters answers `[]`; `429 TOO_MANY_SEARCHES` beyond 30 searches per user and minute, `app.follow.search-per-minute`) | 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 | | `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/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 | | `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 |
@@ -295,9 +324,9 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
- PostgreSQL 17, Flyway migrations - PostgreSQL 17, Flyway migrations
- Resilience4j: retry and rate limiter for Steam, rate limiters for IGDB and speedrun.com - Resilience4j: retry and rate limiter for Steam, rate limiters for IGDB and speedrun.com
- JJWT with Ed25519 public key - JJWT with Ed25519 public key
- Spring Boot Actuator + Micrometer (Prometheus) - Spring Boot Actuator (`/actuator/health` only)
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit - Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
- CI: Forgejo Actions, multi-stage Docker image - CI: Gitea Actions (`.gitea/workflows/publish-core.yml`), multi-stage Docker image
## Architecture ## Architecture
@@ -338,8 +367,8 @@ flowchart LR
Psn -->|"NPSSO sign-in, played titles, trophy sets and lists"| PsnApi["PlayStation API (unofficial)"] Psn -->|"NPSSO sign-in, played titles, trophy sets and lists"| PsnApi["PlayStation API (unofficial)"]
LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user; inbox_note (PSN expiry notes)"| Db[("PostgreSQL tbp_core")] 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 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, public_user, sync_change, user_game_status_change, account_name"| 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 --> 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 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 Account -->|"per-user rows, deleted_user, blocked_user, listed_user, public_user, follow, account_name, user_seen_at"| Db
``` ```
@@ -350,7 +379,7 @@ flowchart LR
| `stats` | Public numbers for the landing page | | `stats` | Public numbers for the landing page |
| `version` | `GET /api/version`: commit and build time of the running image | | `version` | `GET /api/version`: commit and build time of the running image |
| `follow` | Follows (`follow`), the digest cursor per followed user (`follow_digest`) and `FollowDigestService`: one note per follower and batch, from `sync_change` and the hand rows of `user_game_status_change` | | `follow` | Follows (`follow`), the digest cursor per followed user (`follow_digest`) and `FollowDigestService`: one note per follower and batch, from `sync_change` and the hand rows of `user_game_status_change` |
| `inbox` | `inbox_note`: the notes of a user, `GET /api/inbox`, the 90-day purge | | `inbox` | `inbox_note`, `inbox_note_detail`: the notes of a user, `GET /api/inbox`, `GET /api/inbox/{id}/details`, the 90-day purge |
| `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), public profiles and their sections in `public_user` (#27), 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), public profiles and their sections in `public_user` (#27), 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 | | `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 | | `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
@@ -393,8 +422,10 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
### Sync flow ### Sync flow
1. `GET /api/library/games`, `/dashboard-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. when the last success is older than 20 hours (`app.library.sync-ttl-hours`) and the last try older than 60 minutes
`POST /api/library/sync` starts one at once, but at most every 60 seconds (else `429` with `Retry-After`). (`login-retry-minutes`, also after a failure), or when it was for another Steam ID.
`POST /api/library/sync` starts one at once, but at most every 60 seconds (`force-sync-cooldown-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 2. A nightly job (23:55, Europe/Berlin) syncs every account used in the last 14 days, one after the other. A
request without a `steamId` claim (the user unlinked the Steam account in Auth) takes the user out of it. request without a `steamId` claim (the user unlinked the Steam account in Auth) takes the user out of it.
Afterwards it writes every user's pile size of the day to `backlog_snapshot`, also for users without Steam, and Afterwards it writes every user's pile size of the day to `backlog_snapshot`, also for users without Steam, and
@@ -458,18 +489,20 @@ At 01:30 `ImageRepairService` repairs the pictures reported through `POST /api/l
### Background work ### Background work
Background work runs on two pools (`AsyncConfig`; every `@Async` names one, ArchUnit checks it): Background work runs on three pools (`AsyncConfig`; every `@Async` names one, ArchUnit checks it):
- The default pool (4 threads, queue 100) runs the syncs and the time-to-beat and mood refreshes a page view - The default pool (4 threads, queue 100) runs the syncs and the time-to-beat and mood refreshes a page view
starts. A page view queues only games without a fresh row. When the queue is full, the task is dropped with starts. A page view queues only games without a fresh row. When the queue is full, the task is dropped with
a warning, and the next view or sync asks again. Known limit: a dropped sync still answers `202`; the user a warning, and the next view or sync asks again. Known limit: a dropped sync still answers `202`; the user
starts it again. starts it again.
- The follow-up pool (2 threads, no limit on the queue) runs the achievement runs after a sync and every speedrun - The follow-up pool (2 threads, no limit on the queue) runs the achievement runs after a sync and the speedrun
refresh. They hold a thread for minutes because of the Steam and speedrun.com pace, so they do not take the page refreshes (`@Async`). They hold a thread for minutes because of the Steam and speedrun.com pace, so they do not take
views' threads. A task is small (a user or a few appids), and none is dropped. the page views' threads. A task is small (a user or a few appids), and none is dropped.
- The speedrun drainer (`speedrunExecutor`, 1 thread) works the speedrun.com queue (`SpeedrunService`).
Imports have their own two threads, and `game_import` is their queue. Scheduled jobs (nightly sync, image repair, Imports run in two lanes, `game-import-match` and `game-import-save`, 1 thread each, queue 1; `game_import` is their
import watchdog) run on the scheduler. queue. Scheduled jobs run on the scheduler: nightly sync 23:55, image repair 01:30, rewards settle 02:30, inbox purge
03:15, merge-undo purge 03:45, import watchdog every minute, credit backfill every 30 s, follow digest every 10 min.
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -477,10 +510,12 @@ flowchart LR
Sync["sync request"] --> Pool Sync["sync request"] --> Pool
Synced["sync, after commit"] --> FollowUp["follow-up pool<br/>2 threads"] Synced["sync, after commit"] --> FollowUp["follow-up pool<br/>2 threads"]
View -->|"speedrun"| FollowUp View -->|"speedrun"| FollowUp
FollowUp --> Drainer["speedrunExecutor<br/>1 thread"]
Pool --> Apis["Steam, IGDB, speedrun.com"] Pool --> Apis["Steam, IGDB, speedrun.com"]
FollowUp --> Apis FollowUp --> Apis
Drainer --> Apis
Import["POST /imports"] --> Lanes["GameImportWorker<br/>2 own threads"] Import["POST /imports"] --> Lanes["GameImportWorker<br/>2 own threads"]
Cron["scheduler"] --> Jobs["nightly sync, image repair, import watchdog"] Cron["scheduler"] --> Jobs["nightly sync, image repair, rewards settle, inbox purge, merge-undo purge, import watchdog, credit backfill, follow digest"]
``` ```
Core runs as one instance. The pools, the speedrun queue, the per-user achievement runs and the achievement locks Core runs as one instance. The pools, the speedrun queue, the per-user achievement runs and the achievement locks
@@ -753,7 +788,9 @@ project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` h
- Every `LINKED` or `STEAM_OWNED` title also keeps its own platform, minutes and last played in `user_psn_title` (V44), - Every `LINKED` or `STEAM_OWNED` title also keeps its own platform, minutes and last played in `user_psn_title` (V44),
so the detail shows PlayStation play next to Steam's on a shared row. The Steam row's own playtime stays Steam's. so the detail shows PlayStation play next to Steam's on a shared row. The Steam row's own playtime stays Steam's.
- A `LINKED` title updates its row (`source = PSN`, not removed): playtime, `last_played_at`, `PLAYTIME` changes and the - A `LINKED` title updates its row (`source = PSN`, not removed): playtime, `last_played_at`, `PLAYTIME` changes and the
playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. A game the user removed stays playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. The minutes a title grew since
its stored play are booked on its last played day (Berlin), only when its last played moved; a title linked in this
run is the start point and books nothing (V49 dropped the old wrong rows). A game the user removed stays
`LINKED` and the sync does not add it again; its row stays removed, so the sync skips it until the user restores it: `LINKED` and the sync does not add it again; its row stays removed, so the sync skips it until the user restores it:
a restore takes the stored title play (`psn_title_play`) and the sync updates the row again. Flyway V46 gave games a restore takes the stored title play (`psn_title_play`) and the sync updates the row again. Flyway V46 gave games
removed before (soft delete) a removed `PSN` row and turned a `MANUAL` row re-added on a `LINKED` key back to `PSN`. A game also owned on Steam stays one row (the Steam row wins). removed before (soft delete) a removed `PSN` row and turned a `MANUAL` row re-added on a `LINKED` key back to `PSN`. A game also owned on Steam stays one row (the Steam row wins).
@@ -807,7 +844,7 @@ other, then start them in this order (Core also starts without Auth and finds it
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173` 3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development (`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`. values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8 4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 30
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers. games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
What the `local` profile does: What the `local` profile does:
@@ -848,10 +885,11 @@ Needs Java 25, Docker and your own keys.
``` ```
Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the
service listens on `http://localhost:8090`. Health: `/actuator/health`, metrics: `/actuator/prometheus`. service listens on `http://localhost:8090`. Health: `/actuator/health` (the only open actuator endpoint).
In the `dev` profile a request without an `Authorization` header gets a dummy user, so the API is usable without a In the `dev` profile a request without an `Authorization` header gets a dummy user, so the API is usable without a
running Auth service. running Auth service. There is no `application-dev.yml`: `dev` only swaps the JWT filter
(`DevJwtAuthenticationFilter`, `@Profile("dev")`); everything else is `application.yml`.
Configuration comes from environment variables: Configuration comes from environment variables:
@@ -862,7 +900,9 @@ Configuration comes from environment variables:
| `STEAM_API_KEY` | Steam Web API key | | `STEAM_API_KEY` | Steam Web API key |
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) | | `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) |
| `APP_PSN_TOKEN_KEY` | Key for the stored PlayStation tokens, `openssl rand -base64 32` (optional; empty = PlayStation link off) | | `APP_PSN_TOKEN_KEY` | Key for the stored PlayStation tokens, `openssl rand -base64 32` (optional; empty = PlayStation link off) |
| `DB_PASSWORD` | Local PostgreSQL password | | `APP_FRONTEND_URL` | CORS origin, default the production host |
| `APP_ACCOUNT_CLEANUP_DRY_RUN` | Default `true`: the purge and prune only count |
| `APP_BUILD_COMMIT`, `APP_BUILD_TIME` | Feed `GET /api/version`; defaults `dev` and empty. CI passes `GIT_SHA` and `BUILD_TIME` as Docker build args |
### Request timing log ### Request timing log
@@ -871,6 +911,14 @@ duration, e.g. `GET /api/library/games 200 28 ms`. The pattern is `-` when no ha
routing). The line never holds ids, query strings, user data or tokens. `/actuator/**` is skipped. An exception routing). The line never holds ids, query strings, user data or tokens. `/actuator/**` is skipped. An exception
that leaves the filter chain is logged as `500`. that leaves the filter chain is logged as `500`.
### Database connection settings
Every Core connection runs with `plan_cache_mode = force_generic_plan` and `prepareThreshold=1`
(`spring.datasource.hikari` in `application.yml`). Planning was 94% of the Dashboard DB time and prod never reused a
plan (few users, a new connection every 30 min). With a cached generic plan the Dashboard burst dropped from 443 to
229 ms on 2 CPUs (#49). Cost: the first burst on fresh connections costs about 100 ms more (cold start: 1101 -> 1205
ms), and a generic plan ignores parameter values.
## Tests ## Tests
```bash ```bash
@@ -1,14 +1,17 @@
package core_service.modules.follow.api; package core_service.modules.follow.api;
import core_service.modules.follow.api.dto.FollowsDto; import core_service.modules.follow.api.dto.FollowsDto;
import core_service.modules.follow.api.dto.PlayersDto;
import core_service.modules.follow.domain.FollowEntry; import core_service.modules.follow.domain.FollowEntry;
import core_service.modules.follow.domain.FollowResult; import core_service.modules.follow.domain.FollowResult;
import core_service.modules.follow.domain.Follows; import core_service.modules.follow.domain.Follows;
import core_service.modules.follow.domain.PlayerSearch;
import core_service.modules.follow.service.FollowService; import core_service.modules.follow.service.FollowService;
import core_service.shared.api.ErrorResponse; import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
@@ -16,6 +19,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@@ -33,6 +37,20 @@ public class FollowController {
return ResponseEntity.ok(new FollowsDto(toDto(follows.following()), toDto(follows.followers()))); return ResponseEntity.ok(new FollowsDto(toDto(follows.following()), toDto(follows.followers())));
} }
/**
* The caller's own name is a normal hit.
*/
@GetMapping("/players")
public ResponseEntity<?> players(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam("q") @Size(max = 100) String q) {
PlayerSearch search = followService.searchPlayers(principal.getIdAsLong(), q);
if (search.limited()) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(new ErrorResponse("TOO_MANY_SEARCHES", "Too many searches"));
}
return ResponseEntity.ok(new PlayersDto(search.names().stream().map(PlayersDto.Player::new).toList()));
}
@PutMapping("/by-name/{displayName}") @PutMapping("/by-name/{displayName}")
public ResponseEntity<?> follow(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<?> follow(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("displayName") @Size(max = 100) String displayName) { @PathVariable("displayName") @Size(max = 100) String displayName) {
@@ -63,6 +81,6 @@ public class FollowController {
} }
private static List<FollowsDto.Person> toDto(List<FollowEntry> entries) { 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; package core_service.modules.follow.api.dto;
import java.time.Instant;
import java.util.List; import java.util.List;
/** /**
@@ -8,9 +9,10 @@ import java.util.List;
public record FollowsDto(List<Person> following, List<Person> followers) { public record FollowsDto(List<Person> following, List<Person> followers) {
/** /**
* @param id the follow row, to end it; not a user id * @param id the follow row, to end it; not a user id
* @param displayName null for a follower with a private profile * @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) {
} }
} }
@@ -0,0 +1,15 @@
package core_service.modules.follow.api.dto;
import java.util.List;
/**
* Public profiles found by name: names that start with the query first, then by name.
*/
public record PlayersDto(List<Player> results) {
/**
* @param displayName the key of the profile URL
*/
public record Player(String displayName) {
}
}
@@ -9,8 +9,8 @@ import java.util.List;
*/ */
public record Digest(int newGames, int achievements, int statusChanges, int wishlistAdds) { public record Digest(int newGames, int achievements, int statusChanges, int wishlistAdds) {
private static final String SUMMARY = "SUMMARY"; static final String SUMMARY = "SUMMARY";
private static final List<String> STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED"); static final List<String> STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED");
public boolean isEmpty() { public boolean isEmpty() {
return newGames == 0 && achievements == 0 && statusChanges == 0 && wishlistAdds == 0; return newGames == 0 && achievements == 0 && statusChanges == 0 && wishlistAdds == 0;
@@ -0,0 +1,28 @@
package core_service.modules.follow.domain;
/**
* One line behind a digest count. Names are snapshots. The kind is a plain string: the inbox module has its own enum.
*/
public record DigestItem(String kind, long appid, String gameName, String itemName, String fromStatus, String toStatus) {
public static final String NEW_GAME = "NEW_GAME";
public static final String ACHIEVEMENT = "ACHIEVEMENT";
public static final String STATUS = "STATUS";
public static final String WISHLIST = "WISHLIST";
public static DigestItem newGame(long appid, String gameName) {
return new DigestItem(NEW_GAME, appid, gameName, null, null, null);
}
public static DigestItem achievement(long appid, String gameName, String itemName) {
return new DigestItem(ACHIEVEMENT, appid, gameName, itemName, null, null);
}
public static DigestItem status(long appid, String gameName, String from, String to) {
return new DigestItem(STATUS, appid, gameName, null, from, to);
}
public static DigestItem wishlist(long appid, String gameName) {
return new DigestItem(WISHLIST, appid, gameName, null, null, null);
}
}
@@ -0,0 +1,66 @@
package core_service.modules.follow.domain;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* The lines behind a {@link Digest}, in the order they are shown.
*/
public record DigestItems(List<DigestItem> items) {
public DigestItems plus(DigestItems other) {
List<DigestItem> all = new ArrayList<>(items);
all.addAll(other.items);
return new DigestItems(all);
}
/**
* Same rule as {@link Digest#visibleOn}.
*
* @param sections the profile parts the user shows, null = all
*/
public DigestItems visibleOn(Collection<String> sections) {
if (sections == null) {
return this;
}
boolean summary = sections.contains(Digest.SUMMARY);
boolean status = Digest.STATUS_SECTIONS.stream().anyMatch(sections::contains);
return new DigestItems(items.stream().filter(item -> switch (item.kind()) {
case DigestItem.NEW_GAME, DigestItem.ACHIEVEMENT -> summary;
case DigestItem.STATUS -> status;
default -> true;
}).toList());
}
/**
* One STATUS line per game: the first old status to the last new one, dropped when they are equal. The line keeps
* the place of the first move of its game.
*/
public DigestItems collapseStatus() {
Map<Long, String> lastTo = new HashMap<>();
for (DigestItem item : items) {
if (DigestItem.STATUS.equals(item.kind())) {
lastTo.put(item.appid(), item.toStatus());
}
}
List<DigestItem> result = new ArrayList<>();
Set<Long> seen = new HashSet<>();
for (DigestItem item : items) {
if (!DigestItem.STATUS.equals(item.kind())) {
result.add(item);
} else if (seen.add(item.appid())) {
String to = lastTo.get(item.appid());
if (!Objects.equals(item.fromStatus(), to)) {
result.add(DigestItem.status(item.appid(), item.gameName(), item.fromStatus(), to));
}
}
}
return new DigestItems(result);
}
}
@@ -1,8 +1,11 @@
package core_service.modules.follow.domain; package core_service.modules.follow.domain;
import java.time.Instant;
/** /**
* @param id the follow row, not a user id * @param id the follow row, not a user id
* @param displayName null for a follower with a private profile * @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) {
} }
@@ -0,0 +1,17 @@
package core_service.modules.follow.domain;
import java.util.List;
/**
* The answer of a player search: the names found, or "limited" when the caller searched too often.
*/
public record PlayerSearch(List<String> names, boolean limited) {
public static PlayerSearch tooMany() {
return new PlayerSearch(List.of(), true);
}
public static PlayerSearch of(List<String> names) {
return new PlayerSearch(names, false);
}
}
@@ -2,15 +2,19 @@ package core_service.modules.follow.repo;
import core_service.modules.follow.domain.Cursor; import core_service.modules.follow.domain.Cursor;
import core_service.modules.follow.domain.Digest; import core_service.modules.follow.domain.Digest;
import core_service.modules.follow.domain.DigestItem;
import core_service.modules.follow.domain.DigestItems;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.sql.Array; import java.sql.Array;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays; import java.util.Arrays;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -22,6 +26,8 @@ import java.util.Optional;
@RequiredArgsConstructor @RequiredArgsConstructor
public class FollowDigestRepository { public class FollowDigestRepository {
static final int MAX_ACHIEVEMENTS_PER_GAME = 20;
private final NamedParameterJdbcTemplate jdbc; private final NamedParameterJdbcTemplate jdbc;
/** /**
@@ -83,6 +89,17 @@ public class FollowDigestRepository {
return max != null ? max : 0; return max != null ? max : 0;
} }
/**
* The newest hand change after {@code afterId}, or {@code afterId} when there is none. A hand-only digest stops
* there, so the status rows of a sync stay for the digest of that sync.
*/
public long maxHandStatusChangeId(long userId, long afterId) {
Long max = jdbc.queryForObject("select max(id) from user_game_status_change "
+ "where user_id = :userId and by_user and id > :afterId",
user(userId).addValue("afterId", afterId), Long.class);
return max != null ? max : afterId;
}
/** /**
* The changes the user made by hand: no old status = a game added by hand, else a status change. * The changes the user made by hand: no old status = a game added by hand, else a status change.
*/ */
@@ -98,6 +115,90 @@ public class FollowDigestRepository {
(rs, i) -> new Digest(rs.getInt("new_games"), 0, rs.getInt("status_changes"), 0)); (rs, i) -> new Digest(rs.getInt("new_games"), 0, rs.getInt("status_changes"), 0));
} }
/**
* The lines behind {@link #countSync}, same joins and filters. Achievement names are not stored by the sync, only
* how many: the newest unlocks of the game (Steam and PlayStation together) stand for the new ones, at most
* {@link #MAX_ACHIEVEMENTS_PER_GAME} per game.
*/
public DigestItems findSyncItems(long userId) {
List<DigestItem> items = new ArrayList<>(findSyncNewGames(userId));
items.addAll(findSyncAchievements(userId));
return new DigestItems(items);
}
private List<DigestItem> findSyncNewGames(long userId) {
return jdbc.query("""
select distinct c.appid, sg.name from sync_change c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
left join steam_game sg on sg.appid = c.appid
where c.user_id = :userId and c.kind = 'ADDED' and ug.hidden_at is null and ug.removed_at is null
order by sg.name, c.appid""", user(userId), (rs, i) -> DigestItem.newGame(rs.getLong("appid"), rs.getString("name")));
}
private List<DigestItem> findSyncAchievements(long userId) {
return jdbc.query("""
with gained as (
select c.appid, sum(c.amount) as amount from sync_change c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
where c.user_id = :userId and c.kind = 'ACHIEVEMENTS' and ug.hidden_at is null and ug.removed_at is null
group by c.appid),
unlocks as (
select a.appid, coalesce(s.display_name, a.api_name) as name, a.unlocked_at as at
from user_game_achievement a
left join game_achievement_schema s on s.appid = a.appid and s.api_name = a.api_name
where a.user_id = :userId and a.unlocked
union all
select st.appid, coalesce(t.name, 'Trophy ' || t.trophy_id), ut.earned_at
from user_psn_trophy ut
join user_psn_trophy_set st on st.user_id = ut.user_id and st.np_communication_id = ut.np_communication_id
join psn_trophy t on t.np_communication_id = ut.np_communication_id and t.trophy_id = ut.trophy_id
where ut.user_id = :userId),
ranked as (
select u.appid, u.name, row_number() over (partition by u.appid order by u.at desc nulls last, u.name) as rn
from unlocks u join gained g on g.appid = u.appid)
select r.appid, sg.name as game_name, r.name from ranked r
join gained g on g.appid = r.appid
left join steam_game sg on sg.appid = r.appid
where r.rn <= least(g.amount, :cap)
order by sg.name, r.appid, r.rn""", user(userId).addValue("cap", MAX_ACHIEVEMENTS_PER_GAME),
(rs, i) -> DigestItem.achievement(rs.getLong("appid"), rs.getString("game_name"), rs.getString("name")));
}
/**
* The lines behind {@link #countHand}, same filters. With {@code withSyncMoves} the status moves of the sync are
* read from the same log, so a game's moves by hand and by sync stay in time order for the collapse.
*/
public DigestItems findHandItems(long userId, long afterId, long upToId, boolean withSyncMoves) {
return new DigestItems(jdbc.query("""
select c.appid, sg.name, c.from_status, c.to_status
from user_game_status_change c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
left join steam_game sg on sg.appid = c.appid
where c.user_id = :userId and c.id > :afterId and c.id <= :upToId
and (c.by_user or (:withSyncMoves and c.from_status is not null))
and ug.hidden_at is null and ug.removed_at is null
order by c.id""",
user(userId).addValue("afterId", afterId).addValue("upToId", upToId).addValue("withSyncMoves", withSyncMoves),
(rs, i) -> rs.getString("from_status") == null
? DigestItem.newGame(rs.getLong("appid"), rs.getString("name"))
: DigestItem.status(rs.getLong("appid"), rs.getString("name"), rs.getString("from_status"),
rs.getString("to_status"))));
}
/**
* The lines behind {@link #countWishlist}, same id range.
*/
public DigestItems findWishlistItems(long userId, long afterId, long upToId) {
return new DigestItems(jdbc.query("""
select c.appid, coalesce(w.name, sg.name, 'App ' || c.appid) as name from sync_change c
left join wishlist_game w on w.user_id = c.user_id and w.appid = c.appid
left join steam_game sg on sg.appid = c.appid
where c.user_id = :userId and c.kind = 'WISHLIST' and c.id > :afterId and c.id <= :upToId
order by c.id""",
user(userId).addValue("afterId", afterId).addValue("upToId", upToId),
(rs, i) -> DigestItem.wishlist(rs.getLong("appid"), rs.getString("name"))));
}
/** /**
* @return null when there is no row or the user did not send sections: nothing is filtered * @return null when there is no row or the user did not send sections: nothing is filtered
*/ */
@@ -115,10 +216,10 @@ public class FollowDigestRepository {
* One note per follower, only while the followed user is public, not blocked and not deleted and the follower is * One note per follower, only while the followed user is public, not blocked and not deleted and the follower is
* not deleted. A name is stored as it is now. * not deleted. A name is stored as it is now.
* *
* @return how many notes were written * @return the ids of the notes written
*/ */
public int insertDigestNotes(long userId, Digest digest) { public List<Long> insertDigestNotes(long userId, Digest digest) {
return jdbc.update(""" return jdbc.query("""
insert into inbox_note (user_id, kind, subject_user_id, subject_name, new_games, achievements, insert into inbox_note (user_id, kind, subject_user_id, subject_name, new_games, achievements,
status_changes, wishlist_adds) status_changes, wishlist_adds)
select f.user_id, 'DIGEST', f.followed_user_id, select f.user_id, 'DIGEST', f.followed_user_id,
@@ -128,9 +229,32 @@ public class FollowDigestRepository {
where f.followed_user_id = :userId where f.followed_user_id = :userId
and exists (select 1 from public_user p where p.user_id = f.followed_user_id) and exists (select 1 from public_user p where p.user_id = f.followed_user_id)
and not exists (select 1 from blocked_user b where b.user_id = f.followed_user_id) and not exists (select 1 from blocked_user b where b.user_id = f.followed_user_id)
and not exists (select 1 from deleted_user d where d.user_id in (f.followed_user_id, f.user_id))""", and not exists (select 1 from deleted_user d where d.user_id in (f.followed_user_id, f.user_id))
returning id""",
user(userId).addValue("newGames", digest.newGames()).addValue("achievements", digest.achievements()) user(userId).addValue("newGames", digest.newGames()).addValue("achievements", digest.achievements())
.addValue("statusChanges", digest.statusChanges()).addValue("wishlistAdds", digest.wishlistAdds())); .addValue("statusChanges", digest.statusChanges()).addValue("wishlistAdds", digest.wishlistAdds()),
(rs, i) -> rs.getLong("id"));
}
/**
* One copy of the lines per note: the note owns them and they go with it.
*/
public void insertDigestItems(List<Long> noteIds, DigestItems items) {
if (noteIds.isEmpty() || items.items().isEmpty()) {
return;
}
List<SqlParameterSource> rows = new ArrayList<>();
for (long noteId : noteIds) {
for (DigestItem item : items.items()) {
rows.add(new MapSqlParameterSource("noteId", noteId).addValue("kind", item.kind())
.addValue("appid", item.appid()).addValue("gameName", item.gameName())
.addValue("itemName", item.itemName()).addValue("fromStatus", item.fromStatus())
.addValue("toStatus", item.toStatus()));
}
}
jdbc.batchUpdate("insert into inbox_note_detail (note_id, kind, appid, game_name, item_name, from_status, to_status) "
+ "values (:noteId, :kind, :appid, :gameName, :itemName, :fromStatus, :toStatus)",
rows.toArray(new SqlParameterSource[0]));
} }
public void moveCursor(long userId, Instant syncStartedAt, long statusChangeId, long wishlistChangeId) { public void moveCursor(long userId, Instant syncStartedAt, long statusChangeId, long wishlistChangeId) {
@@ -1,12 +1,14 @@
package core_service.modules.follow.repo; package core_service.modules.follow.repo;
import core_service.modules.follow.domain.FollowEntry; import core_service.modules.follow.domain.FollowEntry;
import core_service.shared.sql.LikePattern;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -19,9 +21,13 @@ import java.util.Optional;
public class FollowRepository { public class FollowRepository {
static final int LIST_LIMIT = 500; static final int LIST_LIMIT = 500;
public static final int SEARCH_LIMIT = 20;
private static final RowMapper<FollowEntry> ENTRY = (rs, i) -> new FollowEntry(rs.getLong("id"), private static final RowMapper<FollowEntry> ENTRY = (rs, i) -> {
rs.getString("display_name")); 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; private final NamedParameterJdbcTemplate jdbc;
@@ -41,6 +47,23 @@ public class FollowRepository {
return ids.size() == 1 ? Optional.of(ids.get(0)) : Optional.empty(); return ids.size() == 1 ? Optional.of(ids.get(0)) : Optional.empty();
} }
/**
* Public names that contain the query, case-insensitive; names that start with it come first. Same visibility
* rules as {@link #findFollowable}. Wildcards in the query match themselves.
*/
public List<String> searchPublicNames(String query, int limit) {
String escaped = LikePattern.escapeLower(query);
return jdbc.queryForList("""
select a.display_name from account_name a
join public_user p on p.user_id = a.user_id
where lower(a.display_name) like :pattern escape '\\'
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)
order by (lower(a.display_name) like :prefix escape '\\') desc, lower(a.display_name)
limit :limit""", new MapSqlParameterSource("pattern", "%" + escaped + "%")
.addValue("prefix", escaped + "%").addValue("limit", limit), String.class);
}
/** /**
* Checks the visibility again in the same statement, so a user who went private in between gets no follower. * Checks the visibility again in the same statement, so a user who went private in between gets no follower.
* *
@@ -91,7 +114,11 @@ public class FollowRepository {
public List<FollowEntry> findFollowing(long userId) { public List<FollowEntry> findFollowing(long userId) {
return jdbc.query(""" 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 left join account_name a on a.user_id = f.followed_user_id
where f.user_id = :userId where f.user_id = :userId
order by lower(a.display_name), f.id order by lower(a.display_name), f.id
@@ -103,7 +130,8 @@ public class FollowRepository {
*/ */
public List<FollowEntry> findFollowers(long userId) { public List<FollowEntry> findFollowers(long userId) {
return jdbc.query(""" 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 from follow f
left join public_user p on p.user_id = f.user_id left join public_user p on p.user_id = f.user_id
left join account_name a on a.user_id = f.user_id left join account_name a on a.user_id = f.user_id
@@ -2,6 +2,7 @@ package core_service.modules.follow.service;
import core_service.modules.follow.domain.Cursor; import core_service.modules.follow.domain.Cursor;
import core_service.modules.follow.domain.Digest; import core_service.modules.follow.domain.Digest;
import core_service.modules.follow.domain.DigestItems;
import core_service.modules.follow.repo.FollowDigestRepository; import core_service.modules.follow.repo.FollowDigestRepository;
import core_service.shared.event.LibraryActivityEvent; import core_service.shared.event.LibraryActivityEvent;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -19,7 +20,8 @@ import java.util.Optional;
/** /**
* One inbox note per follower and batch. A batch is a sync with its achievement run, plus the hand changes since the * One inbox note per follower and batch. A batch is a sync with its achievement run, plus the hand changes since the
* last batch. The cursor moves in every case, so what the profile hides is never reported later. * last batch. The cursor moves in every case, so what the profile hides is never reported later. Each note also gets
* the lines behind its counts (status moves collapsed per game).
*/ */
@Service @Service
@Slf4j @Slf4j
@@ -67,23 +69,31 @@ public class FollowDigestService {
return; return;
} }
Cursor cursor = locked.get(); Cursor cursor = locked.get();
long upTo = digestRepository.maxStatusChangeId(userId); long upTo = withSync ? digestRepository.maxStatusChangeId(userId)
: digestRepository.maxHandStatusChangeId(userId, cursor.statusChangeId());
Digest digest = digestRepository.countHand(userId, cursor.statusChangeId(), upTo); Digest digest = digestRepository.countHand(userId, cursor.statusChangeId(), upTo);
Instant syncStartedAt = cursor.syncStartedAt(); Instant syncStartedAt = cursor.syncStartedAt();
long wishlistUpTo = cursor.wishlistChangeId(); long wishlistUpTo = cursor.wishlistChangeId();
Optional<Instant> current = withSync ? digestRepository.findSyncStartedAt(userId) : Optional.empty();
boolean newSync = current.isPresent() && (syncStartedAt == null || current.get().isAfter(syncStartedAt));
DigestItems items = digestRepository.findHandItems(userId, cursor.statusChangeId(), upTo, newSync);
if (withSync) { if (withSync) {
Optional<Instant> current = digestRepository.findSyncStartedAt(userId); if (newSync) {
if (current.isPresent() && (syncStartedAt == null || current.get().isAfter(syncStartedAt))) {
digest = digest.plus(digestRepository.countSync(userId)); digest = digest.plus(digestRepository.countSync(userId));
items = items.plus(digestRepository.findSyncItems(userId));
syncStartedAt = current.get(); syncStartedAt = current.get();
} }
wishlistUpTo = digestRepository.maxWishlistChangeId(userId); wishlistUpTo = digestRepository.maxWishlistChangeId(userId);
digest = digest.plus(new Digest(0, 0, 0, digest = digest.plus(new Digest(0, 0, 0,
digestRepository.countWishlist(userId, cursor.wishlistChangeId(), wishlistUpTo))); digestRepository.countWishlist(userId, cursor.wishlistChangeId(), wishlistUpTo)));
items = items.plus(digestRepository.findWishlistItems(userId, cursor.wishlistChangeId(), wishlistUpTo));
} }
digest = digest.visibleOn(digestRepository.findPublicSections(userId)); List<String> sections = digestRepository.findPublicSections(userId);
digest = digest.visibleOn(sections);
items = items.visibleOn(sections).collapseStatus();
if (!digest.isEmpty()) { if (!digest.isEmpty()) {
digestRepository.insertDigestNotes(userId, digest); List<Long> noteIds = digestRepository.insertDigestNotes(userId, digest);
digestRepository.insertDigestItems(noteIds, items);
} }
digestRepository.moveCursor(userId, syncStartedAt, upTo, wishlistUpTo); digestRepository.moveCursor(userId, syncStartedAt, upTo, wishlistUpTo);
}); });
@@ -2,6 +2,7 @@ package core_service.modules.follow.service;
import core_service.modules.follow.domain.FollowResult; import core_service.modules.follow.domain.FollowResult;
import core_service.modules.follow.domain.Follows; import core_service.modules.follow.domain.Follows;
import core_service.modules.follow.domain.PlayerSearch;
import core_service.modules.follow.repo.FollowRepository; import core_service.modules.follow.repo.FollowRepository;
import core_service.shared.security.RequiresRole; import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role; import core_service.shared.security.Role;
@@ -9,6 +10,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional; import java.util.Optional;
@Service @Service
@@ -17,8 +19,10 @@ import java.util.Optional;
public class FollowService { public class FollowService {
static final int MAX_FOLLOWING = 500; static final int MAX_FOLLOWING = 500;
static final int MIN_QUERY_LENGTH = 2;
private final FollowRepository followRepository; private final FollowRepository followRepository;
private final PlayerSearchLimiter limiter;
/** /**
* Unknown, private, blocked and deleted names all answer NOT_FOUND, so a name cannot be probed. * Unknown, private, blocked and deleted names all answer NOT_FOUND, so a name cannot be probed.
@@ -57,4 +61,20 @@ public class FollowService {
public Follows follows(long userId) { public Follows follows(long userId) {
return new Follows(followRepository.findFollowing(userId), followRepository.findFollowers(userId)); return new Follows(followRepository.findFollowing(userId), followRepository.findFollowers(userId));
} }
/**
* A query shorter than two characters finds nothing and does not count against the limit.
*/
@RequiresRole(Role.USER)
@Transactional(readOnly = true)
public PlayerSearch searchPlayers(long userId, String query) {
String stripped = query.strip();
if (stripped.length() < MIN_QUERY_LENGTH) {
return PlayerSearch.of(List.of());
}
if (!limiter.allow(userId)) {
return PlayerSearch.tooMany();
}
return PlayerSearch.of(followRepository.searchPublicNames(stripped, FollowRepository.SEARCH_LIMIT));
}
} }
@@ -0,0 +1,32 @@
package core_service.modules.follow.service;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;
/**
* Searches per user and minute. One limiter per user who searched since boot stays in memory: small, and bounded by
* the number of users. The limit holds per instance.
*/
@Component
public class PlayerSearchLimiter {
private final RateLimiterConfig config;
private final ConcurrentHashMap<Long, RateLimiter> limiters = new ConcurrentHashMap<>();
public PlayerSearchLimiter(@Value("${app.follow.search-per-minute:30}") int perMinute) {
this.config = RateLimiterConfig.custom()
.limitForPeriod(perMinute)
.limitRefreshPeriod(Duration.ofMinutes(1))
.timeoutDuration(Duration.ZERO)
.build();
}
public boolean allow(long userId) {
return limiters.computeIfAbsent(userId, id -> RateLimiter.of("player-search-" + id, config)).acquirePermission();
}
}
@@ -1,13 +1,17 @@
package core_service.modules.inbox.api; package core_service.modules.inbox.api;
import core_service.modules.inbox.api.dto.InboxDto; import core_service.modules.inbox.api.dto.InboxDto;
import core_service.modules.inbox.api.dto.NoteDetailsDto;
import core_service.modules.inbox.domain.Inbox; import core_service.modules.inbox.domain.Inbox;
import core_service.modules.inbox.service.InboxService; import core_service.modules.inbox.service.InboxService;
import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -23,11 +27,22 @@ public class InboxController {
public ResponseEntity<InboxDto> inbox(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<InboxDto> inbox(@AuthenticationPrincipal UserPrincipal principal) {
Inbox inbox = inboxService.inbox(principal.getIdAsLong()); Inbox inbox = inboxService.inbox(principal.getIdAsLong());
return ResponseEntity.ok(new InboxDto(inbox.notes().stream() 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.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read(), note.details()))
.toList(), inbox.unread())); .toList(), inbox.unread()));
} }
@GetMapping("/{id}/details")
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.appid(), detail.kind().name(), detail.gameName(),
detail.itemName(), detail.fromStatus(), detail.toStatus()))
.toList())))
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("NOTE_NOT_FOUND", "No such note")));
}
@PutMapping("/read") @PutMapping("/read")
public ResponseEntity<Void> markAllRead(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<Void> markAllRead(@AuthenticationPrincipal UserPrincipal principal) {
inboxService.markAllRead(principal.getIdAsLong()); inboxService.markAllRead(principal.getIdAsLong());
@@ -11,8 +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 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 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) { int wishlistAdds, Instant createdAt, boolean read, boolean details) {
} }
} }
@@ -0,0 +1,16 @@
package core_service.modules.inbox.api.dto;
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(long appid, String kind, String gameName, String itemName, String fromStatus, String toStatus) {
}
}
@@ -4,7 +4,9 @@ import java.time.Instant;
/** /**
* @param subjectName the name of the followed user when the note was written * @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) { int statusChanges, int wishlistAdds, Instant createdAt, boolean read, boolean details) {
} }
@@ -0,0 +1,7 @@
package core_service.modules.inbox.domain;
/**
* One line behind a digest note. Names are snapshots from when the note was written.
*/
public record NoteDetail(long appid, NoteDetailKind kind, String gameName, String itemName, String fromStatus, String toStatus) {
}
@@ -0,0 +1,29 @@
package core_service.modules.inbox.domain;
import java.util.Collection;
import java.util.List;
public enum NoteDetailKind {
NEW_GAME,
ACHIEVEMENT,
STATUS,
WISHLIST;
private static final List<String> STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED");
/**
* Mirror of follow.domain.Digest.visibleOn, modules may not import each other.
*
* @param sections the profile parts the user shows, null = all
*/
public boolean visibleOn(Collection<String> sections) {
if (sections == null) {
return true;
}
return switch (this) {
case NEW_GAME, ACHIEVEMENT -> sections.contains("SUMMARY");
case STATUS -> STATUS_SECTIONS.stream().anyMatch(sections::contains);
case WISHLIST -> true;
};
}
}
@@ -1,15 +1,20 @@
package core_service.modules.inbox.repo; package core_service.modules.inbox.repo;
import core_service.modules.inbox.domain.InboxNote; import core_service.modules.inbox.domain.InboxNote;
import core_service.modules.inbox.domain.NoteDetail;
import core_service.modules.inbox.domain.NoteDetailKind;
import core_service.modules.inbox.domain.NoteKind; import core_service.modules.inbox.domain.NoteKind;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.sql.Array;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Optional;
@Repository @Repository
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -19,14 +24,67 @@ public class InboxRepository {
public List<InboxNote> findNewest(long userId, int limit) { public List<InboxNote> findNewest(long userId, int limit) {
return jdbc.query(""" return jdbc.query("""
select id, kind, subject_name, new_games, achievements, status_changes, wishlist_adds, created_at, read_at select n.id, n.kind, n.subject_name, n.new_games, n.achievements, n.status_changes, n.wishlist_adds,
from inbox_note where user_id = :userId n.created_at, n.read_at,
order by created_at desc, id desc (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
from inbox_note n where n.user_id = :userId
order by n.created_at desc, n.id desc
limit :limit""", new MapSqlParameterSource("userId", userId).addValue("limit", limit), limit :limit""", new MapSqlParameterSource("userId", userId).addValue("limit", limit),
(rs, i) -> new InboxNote(rs.getLong("id"), NoteKind.valueOf(rs.getString("kind")), (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.getInt("status_changes"), rs.getInt("wishlist_adds"),
rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("read_at") != null)); rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("read_at") != null,
rs.getBoolean("details")));
}
/**
* Ownership and the follow re-check in one statement.
*
* @return the followed user of a DIGEST note of this user, empty when the note is not theirs, they no longer
* follow the subject or the subject is no longer public
*/
public Optional<Long> findFollowedSubject(long userId, long noteId) {
return jdbc.query("""
select n.subject_user_id from inbox_note n
where n.id = :noteId and n.user_id = :userId and n.kind = 'DIGEST'
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)""",
new MapSqlParameterSource("userId", userId).addValue("noteId", noteId),
(rs, i) -> rs.getLong("subject_user_id")).stream().findFirst();
}
/**
* @return null when there is no row or the user did not send sections: nothing is filtered
*/
public List<String> findPublicSections(long subjectUserId) {
return jdbc.query("select sections from public_user where user_id = :userId",
new MapSqlParameterSource("userId", subjectUserId), rs -> {
if (!rs.next()) {
return null;
}
Array sections = rs.getArray("sections");
return sections == null ? null : Arrays.asList((String[]) sections.getArray());
});
}
/**
* Without the games the subject hides now.
*/
public List<NoteDetail> findDetails(long noteId, long subjectUserId) {
return jdbc.query("""
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(rs.getLong("appid"), NoteDetailKind.valueOf(rs.getString("kind")), rs.getString("game_name"),
rs.getString("item_name"), rs.getString("from_status"), rs.getString("to_status")));
} }
public int countUnread(long userId) { public int countUnread(long userId) {
@@ -1,6 +1,7 @@
package core_service.modules.inbox.service; package core_service.modules.inbox.service;
import core_service.modules.inbox.domain.Inbox; import core_service.modules.inbox.domain.Inbox;
import core_service.modules.inbox.domain.NoteDetail;
import core_service.modules.inbox.repo.InboxRepository; import core_service.modules.inbox.repo.InboxRepository;
import core_service.shared.security.RequiresRole; import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role; import core_service.shared.security.Role;
@@ -13,6 +14,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List;
import java.util.Optional;
@Service @Service
@Slf4j @Slf4j
@@ -32,6 +35,22 @@ public class InboxService {
return new Inbox(inboxRepository.findNewest(userId, LIST_SIZE), inboxRepository.countUnread(userId)); return new Inbox(inboxRepository.findNewest(userId, LIST_SIZE), inboxRepository.countUnread(userId));
} }
/**
* Checked again on every read: the caller must own the note, still follow the subject, the subject must be
* public and show the part.
*
* @return empty when the note cannot be opened
*/
@RequiresRole(Role.USER)
@Transactional(readOnly = true)
public Optional<List<NoteDetail>> details(long userId, long noteId) {
return inboxRepository.findFollowedSubject(userId, noteId).map(subject -> {
List<String> sections = inboxRepository.findPublicSections(subject);
return inboxRepository.findDetails(noteId, subject).stream()
.filter(detail -> detail.kind().visibleOn(sections)).toList();
});
}
@RequiresRole(Role.USER) @RequiresRole(Role.USER)
@Transactional @Transactional
public void markAllRead(long userId) { public void markAllRead(long userId) {
@@ -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.BrokenImageReport;
import core_service.modules.library.api.dto.CatalogGameDto; import core_service.modules.library.api.dto.CatalogGameDto;
import core_service.modules.library.api.dto.CatalogGameDetailDto; 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.CatalogHitDto;
import core_service.modules.library.api.dto.CorrectionMarkDto; import core_service.modules.library.api.dto.CorrectionMarkDto;
import core_service.modules.library.api.dto.GameDetailDto; 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.CatalogHit;
import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; 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.LinkPair;
import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.PickSource; import core_service.modules.library.domain.PickSource;
@@ -101,7 +103,8 @@ public class LibraryController {
private static final int MAX_PICKER_EXCLUDES = 100; private static final int MAX_PICKER_EXCLUDES = 100;
private static final int MAX_BACKLOG_GAMES = 20; private static final int MAX_BACKLOG_GAMES = 20;
private static final int MAX_DASHBOARD_GAMES = 20; private static final int MAX_DASHBOARD_GAMES = 20;
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority"); private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority",
"name", "lastPlayedAt", "timeToBeat");
private final LibrarySyncService syncService; private final LibrarySyncService syncService;
private final LibraryQueryService queryService; private final LibraryQueryService queryService;
@@ -189,6 +192,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}") @GetMapping("/games/{appid}")
public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid, @PathVariable("appid") Long appid,
@@ -367,13 +378,16 @@ public class LibraryController {
} }
private static CatalogGameDetailDto toDto(CatalogGameDetail d) { 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(), 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(), d.genres(), toDto(d.timeToBeat()),
t.getNormallyMinutes(), t.getCompletelyMinutes()),
d.corrections().stream().map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList()); 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) { private static CatalogHitDto toDto(CatalogHit hit) {
return new CatalogHitDto(hit.appid(), hit.name(), hit.imageUrl(), hit.platforms()); 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.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; 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.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@@ -38,6 +39,17 @@ public class PublicProfileController {
.orElseGet(PublicProfileController::invalidTicket); .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") @GetMapping("/library/stats/games")
public ResponseEntity<?> getPublicStatGames(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket, public ResponseEntity<?> getPublicStatGames(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket,
@RequestParam StatKey key, @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) {
}
@@ -1,10 +1,17 @@
package core_service.modules.library.domain; package core_service.modules.library.domain;
import java.time.Instant;
/** /**
* A PlayStation title the sync has seen for a user (V39). A LINKED title has the library key of the row PlayStation * A PlayStation title the sync has seen for a user (V39). A LINKED title has the library key of the row PlayStation
* made or feeds; STEAM_OWNED has the key of a Steam row it only matched; DISMISSED titles were left unchecked in a review. * made or feeds; STEAM_OWNED has the key of a Steam row it only matched; DISMISSED titles were left unchecked in a review.
* The stored play (playtimeMinutes, lastPlayedAt) is what the last sync saw; both are null until the first savePlays.
*/ */
public record PsnTitle(String psnKey, State state, Long appid) { public record PsnTitle(String psnKey, State state, Long appid, Integer playtimeMinutes, Instant lastPlayedAt) {
public PsnTitle(String psnKey, State state, Long appid) {
this(psnKey, state, appid, null, null);
}
public enum State { LINKED, STEAM_OWNED, DISMISSED } public enum State { LINKED, STEAM_OWNED, DISMISSED }
} }
@@ -264,6 +264,13 @@ public class GameMergeRepository {
return targets; 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 * @return the saved copy of a merge, locked until the transaction ends; empty when there is none
*/ */
@@ -433,7 +440,7 @@ public class GameMergeRepository {
insert into user_game_playtime_day (user_id, day, appid, minutes) insert into user_game_playtime_day (user_id, day, appid, minutes)
select user_id, day, :to, minutes from user_game_playtime_day where user_id = :user and appid = :from select user_id, day, :to, minutes from user_game_playtime_day where user_id = :user and appid = :from
on conflict (user_id, day, appid) do update on conflict (user_id, day, appid) do update
set minutes = user_game_playtime_day.minutes + excluded.minutes set minutes = least(user_game_playtime_day.minutes + excluded.minutes, 1440)
""", params); """, params);
jdbc.update("delete from user_game_playtime_day where user_id = :user and appid = :from", params); jdbc.update("delete from user_game_playtime_day where user_id = :user and appid = :from", params);
jdbc.update("update user_psn_title set appid = :to where user_id = :user and appid = :from", params); jdbc.update("update user_psn_title set appid = :to where user_id = :user and appid = :from", params);
@@ -25,10 +25,11 @@ public class PsnTitleRepository {
public Map<String, PsnTitle> findAll(long userId) { public Map<String, PsnTitle> findAll(long userId) {
Map<String, PsnTitle> titles = new LinkedHashMap<>(); Map<String, PsnTitle> titles = new LinkedHashMap<>();
jdbc.query("select psn_key, state, appid from user_psn_title where user_id = :user", jdbc.query("select psn_key, state, appid, playtime_minutes, last_played_at from user_psn_title where user_id = :user",
new MapSqlParameterSource("user", userId), rs -> { new MapSqlParameterSource("user", userId), rs -> {
titles.put(rs.getString("psn_key"), new PsnTitle(rs.getString("psn_key"), titles.put(rs.getString("psn_key"), new PsnTitle(rs.getString("psn_key"),
PsnTitle.State.valueOf(rs.getString("state")), rs.getObject("appid", Long.class))); PsnTitle.State.valueOf(rs.getString("state")), rs.getObject("appid", Long.class),
rs.getObject("playtime_minutes", Integer.class), instant(rs.getTimestamp("last_played_at"))));
}); });
return titles; return titles;
} }
@@ -10,6 +10,7 @@ import core_service.modules.library.domain.RemovedGame;
import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.StatusCount;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.shared.sql.LikePattern;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -28,7 +29,6 @@ import java.util.EnumMap;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Locale;
import java.util.Optional; import java.util.Optional;
public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> { public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> {
@@ -366,7 +366,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
// ponytail: the view is joined on every page, not only for the playtime sort: about +4 ms per page. Upgrade path: // ponytail: the view is joined on every page, not only for the playtime sort: about +4 ms per page. Upgrade path:
// join it only for the playtime sort or the PLAYING status, with a second query. // join it only for the playtime sort or the PLAYING status, with a second query.
@Query(value = "select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " + @Query(value = "select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
"left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " + OWNED_WHERE, "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " +
"left join GameTimeToBeat tb on tb.appid = ug.appid " + OWNED_WHERE,
countQuery = "select count(ug) from UserGame ug join SteamGame sg on sg.appid = ug.appid " + OWNED_WHERE) countQuery = "select count(ug) from UserGame ug join SteamGame sg on sg.appid = ug.appid " + OWNED_WHERE)
Page<UserGame> findOwnedEntries(@Param("userId") Long userId, Page<UserGame> findOwnedEntries(@Param("userId") Long userId,
@Param("hidden") boolean hidden, @Param("hidden") boolean hidden,
@@ -418,7 +419,9 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
/** /**
* Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last * Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last
* in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and * in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and
* appid breaks every tie so pages do not repeat or skip games. Playtime sorts by the set total. * appid breaks every tie so pages do not repeat or skip games. Playtime sorts by the set total. A game never
* played or without a known time to beat comes last in both directions; the name sorts case-insensitive.
* The time to beat needs {@code left join GameTimeToBeat tb on tb.appid = ug.appid}.
*/ */
static Sort querySort(Sort requested) { static Sort querySort(Sort requested) {
Sort result = Sort.unsorted(); Sort result = Sort.unsorted();
@@ -430,6 +433,10 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
case "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(), case "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(),
"(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)") "(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)")
.iterator().next().nullsLast()); .iterator().next().nullsLast());
case "name" -> Sort.by(JpaSort.unsafe(order.getDirection(), "lower(sg.name)").iterator().next());
case "lastPlayedAt" -> Sort.by(order.nullsLast());
case "timeToBeat" -> Sort.by(JpaSort.unsafe(order.getDirection(), "tb.normallyMinutes")
.iterator().next().nullsLast());
default -> Sort.by(order); default -> Sort.by(order);
}); });
} }
@@ -441,9 +448,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
*/ */
static String containsPattern(String query) { static String containsPattern(String query) {
if (query == null || query.isBlank()) return null; if (query == null || query.isBlank()) return null;
String escaped = query.trim().toLowerCase(Locale.ROOT) return "%" + LikePattern.escapeLower(query.trim()) + "%";
.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
return "%" + escaped + "%";
} }
/** /**
@@ -103,7 +103,7 @@ public class UserStatsRepository {
private static final String PILE_TTB_SET = "select ug.appid, t.normally_minutes as minutes, " + NO_EVENT + ", " private static final String PILE_TTB_SET = "select ug.appid, t.normally_minutes as minutes, " + NO_EVENT + ", "
+ NO_MOVE + " " + PILE_TTB_FROM + "and t.normally_minutes is not null"; + NO_MOVE + " " + PILE_TTB_FROM + "and t.normally_minutes is not null";
private static final String LONGEST_DAY_SET = "select coalesce(e.appid, p.appid) as appid, sum(p.minutes) as minutes, " private static final String LONGEST_DAY_SET = "select coalesce(e.appid, p.appid) as appid, least(sum(p.minutes), 1440) as minutes, "
+ NO_EVENT + ", " + NO_MOVE + " " + ENTRY_DAYS + "and p.day = :arg group by coalesce(e.appid, p.appid)"; + NO_EVENT + ", " + NO_MOVE + " " + ENTRY_DAYS + "and p.day = :arg group by coalesce(e.appid, p.appid)";
private static final String FINISHED_YEAR_SET = "select ug.appid, " + NO_MINUTES + ", " + FINISHED_AT private static final String FINISHED_YEAR_SET = "select ug.appid, " + NO_MINUTES + ", " + FINISHED_AT
+ " as event_at, " + NO_MOVE + " " + FINISHED_FROM + "and " + FINISHED_YEAR + " = :arg"; + " as event_at, " + NO_MOVE + " " + FINISHED_FROM + "and " + FINISHED_YEAR + " = :arg";
@@ -242,7 +242,7 @@ public class UserStatsRepository {
} }
public Optional<DayMinutes> longestDay(long userId) { public Optional<DayMinutes> longestDay(long userId) {
return jdbc.query("select p.day, sum(p.minutes) as minutes " + PLAYTIME_DAYS_NOT_HIDDEN + return jdbc.query("select p.day, least(sum(p.minutes), 1440) as minutes " + PLAYTIME_DAYS_NOT_HIDDEN +
"group by p.day order by minutes desc, p.day desc limit 1", user(userId), "group by p.day order by minutes desc, p.day desc limit 1", user(userId),
(rs, i) -> new DayMinutes(rs.getDate("day").toLocalDate(), rs.getLong("minutes"))) (rs, i) -> new DayMinutes(rs.getDate("day").toLocalDate(), rs.getLong("minutes")))
.stream().findFirst(); .stream().findFirst();
@@ -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.api.dto.PlatformPlayDto;
import core_service.modules.library.domain.AchievementKind; import core_service.modules.library.domain.AchievementKind;
import core_service.modules.library.domain.AchievementProgress; 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.ComingGame;
import core_service.modules.library.domain.Edition; import core_service.modules.library.domain.Edition;
import core_service.modules.library.domain.GameAchievementSchema; 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())); 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 userGame the game after the refresh, re-read when the finish rule changed its status
* @param achievementsStale a due Steam refresh was skipped * @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.PickerCategoryDto;
import core_service.modules.library.api.dto.PickerDto; import core_service.modules.library.api.dto.PickerDto;
import core_service.modules.library.api.dto.PlatformDto; 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.PublicLibraryDto;
import core_service.modules.library.api.dto.RecentGameDto; import core_service.modules.library.api.dto.RecentGameDto;
import core_service.modules.library.api.dto.WishlistDto; 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.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameCreditRepository; import core_service.modules.library.repo.GameCreditRepository;
import core_service.modules.library.repo.GameLinkRepository; 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.GameTimeToBeatRepository;
import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.PsnTrophyRepository; import core_service.modules.library.repo.PsnTrophyRepository;
@@ -108,6 +110,7 @@ public class LibraryQueryService {
private final ReleaseDateService releaseDateService; private final ReleaseDateService releaseDateService;
private final SpeedrunService speedrunService; private final SpeedrunService speedrunService;
private final GameLinkRepository gameLinkRepository; private final GameLinkRepository gameLinkRepository;
private final GameMergeRepository mergeRepository;
private final GameCreditRepository gameCreditRepository; private final GameCreditRepository gameCreditRepository;
private final FriendPlayRepository friendPlayRepository; private final FriendPlayRepository friendPlayRepository;
private final PsnTrophyRepository psnTrophyRepository; private final PsnTrophyRepository psnTrophyRepository;
@@ -390,7 +393,8 @@ public class LibraryQueryService {
Map<LocalDate, Long> played = playtimeDayRepository.minutesPerDay(userId, from).stream() Map<LocalDate, Long> played = playtimeDayRepository.minutesPerDay(userId, from).stream()
.collect(Collectors.toMap(DayMinutes::day, DayMinutes::minutes)); .collect(Collectors.toMap(DayMinutes::day, DayMinutes::minutes));
List<DayMinutes> perDay = from.datesUntil(today.plusDays(1)) List<DayMinutes> perDay = from.datesUntil(today.plusDays(1))
.map(day -> new DayMinutes(day, played.getOrDefault(day, 0L))) // A day holds at most 24 h; rows from before V18/V49 or several games can sum higher.
.map(day -> new DayMinutes(day, Math.min(played.getOrDefault(day, 0L), 1440)))
.toList(); .toList();
long total = perDay.stream().mapToLong(DayMinutes::minutes).sum(); long total = perDay.stream().mapToLong(DayMinutes::minutes).sum();
@@ -447,6 +451,64 @@ public class LibraryQueryService {
shared.isEmpty() ? null : userStatsService.getStats(userId, shared)); 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 * @return null for a hidden section
*/ */
@@ -164,7 +164,7 @@ public class LibrarySyncWorker {
} }
List<SyncChange> steamChanges = hasSteam ? saveGames(userId, steamId, games, syncStartedAt, playedOn) List<SyncChange> steamChanges = hasSteam ? saveGames(userId, steamId, games, syncStartedAt, playedOn)
: new ArrayList<>(); : new ArrayList<>();
List<SyncChange> psnChanges = psnSyncStep.apply(userId, psn, syncStartedAt, syncDay); List<SyncChange> psnChanges = psnSyncStep.apply(userId, psn, syncStartedAt, syncDay, zone);
// The first sync of an account is a baseline: a count, not a list. // The first sync of an account is a baseline: a count, not a list.
int imported = 0; int imported = 0;
List<SyncChange> listed = new ArrayList<>(); List<SyncChange> listed = new ArrayList<>();
@@ -22,6 +22,7 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
@@ -47,17 +48,22 @@ public class PsnSyncStep {
* A LINKED title's PSN values, applied in the sync transaction. * A LINKED title's PSN values, applied in the sync transaction.
* *
* @param lastPlayedAt may be null * @param lastPlayedAt may be null
* @param played minutes to book on the day of lastPlayedAt, 0 when the title is a start point or was not played
* since the last sync
*/ */
public record Playtime(long appid, int minutes, Instant lastPlayedAt) {} public record Playtime(long appid, int minutes, Instant lastPlayedAt, int played) {}
/** /**
* What {@link #run} did outside the transaction. * What {@link #run} did outside the transaction.
* *
* @param changes the ADDED rows of this run * @param changes the ADDED rows of this run
* @param plays the title plays {@link #apply} stores; stored there so a skipped or rolled-back apply keeps the
* old baseline
* @param baseline the user had no PSN title before this run * @param baseline the user had no PSN title before this run
*/ */
public record Prepared(List<SyncChange> changes, List<Playtime> playtimes, boolean baseline) { public record Prepared(List<SyncChange> changes, List<Playtime> playtimes, List<TitlePlay> plays,
public static final Prepared NONE = new Prepared(List.of(), List.of(), false); boolean baseline) {
public static final Prepared NONE = new Prepared(List.of(), List.of(), List.of(), false);
} }
private final PsnAccountService accountService; private final PsnAccountService accountService;
@@ -106,26 +112,38 @@ public class PsnSyncStep {
List<GameImport.Row> review = new ArrayList<>(); List<GameImport.Row> review = new ArrayList<>();
for (GameImport.Line line : lines) { for (GameImport.Line line : lines) {
PsnTitle seen = known.get(line.psnKey()); PsnTitle seen = known.get(line.psnKey());
Instant lastPlayedAt = lastPlayed.get(line.psnKey());
if (seen != null && seen.state() == PsnTitle.State.LINKED) { if (seen != null && seen.state() == PsnTitle.State.LINKED) {
playtimes.add(new Playtime(seen.appid(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))); playtimes.add(new Playtime(seen.appid(), line.playtimeMinutes(), lastPlayedAt,
played(seen, line.playtimeMinutes(), lastPlayedAt)));
} else if (seen == null && !open.contains(line.psnKey())) { } else if (seen == null && !open.contains(line.psnKey())) {
matchNew(userId, line, lastPlayed.get(line.psnKey()), changes, review); matchNew(userId, line, lastPlayedAt, changes, review);
} }
} }
saveReview(userId, review); saveReview(userId, review);
psnTitleRepository.savePlays(userId, lines.stream().map(line -> new TitlePlay(line.psnKey(), null, List<TitlePlay> plays = lines.stream().map(line -> new TitlePlay(line.psnKey(), null,
line.platform(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))).toList()); line.platform(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))).toList();
int added = changes.size(); int added = changes.size();
changes.addAll(trophyStep.run(userId, found.get().accessToken(), titles)); changes.addAll(trophyStep.run(userId, found.get().accessToken(), titles));
log.info("PSN sync user={} added {} titles, {} wait for review, {} games gained trophies", userId, added, log.info("PSN sync user={} added {} titles, {} wait for review, {} games gained trophies", userId, added,
review.size(), changes.size() - added); review.size(), changes.size() - added);
return new Prepared(changes, mergedKeys(playtimes), baseline); return new Prepared(changes, mergedKeys(playtimes), plays, baseline);
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.error("PSN step failed for user {}", userId, e); log.error("PSN step failed for user {}", userId, e);
return new Prepared(changes, playtimes, baseline); return new Prepared(changes, playtimes, List.of(), baseline);
} }
} }
// Only the title's own new play counts: no stored play is a start point, and a total that grew without a newer
// last played is no play of this sync.
private static int played(PsnTitle seen, int minutes, Instant lastPlayedAt) {
if (seen.playtimeMinutes() == null || lastPlayedAt == null
|| (seen.lastPlayedAt() != null && !lastPlayedAt.isAfter(seen.lastPlayedAt()))) {
return 0;
}
return Math.max(0, minutes - seen.playtimeMinutes());
}
private Optional<PsnAccountService.FetchResult.Titles> fetch(Long userId) { private Optional<PsnAccountService.FetchResult.Titles> fetch(Long userId) {
switch (accountService.fetchTitles(userId)) { switch (accountService.fetchTitles(userId)) {
case PsnAccountService.FetchResult.Titles found -> { case PsnAccountService.FetchResult.Titles found -> {
@@ -194,7 +212,7 @@ public class PsnSyncStep {
private List<Playtime> mergedKeys(List<Playtime> playtimes) { private List<Playtime> mergedKeys(List<Playtime> playtimes) {
Map<Long, Long> targets = mergeRepository.findTargets(playtimes.stream().map(Playtime::appid).toList()); Map<Long, Long> targets = mergeRepository.findTargets(playtimes.stream().map(Playtime::appid).toList());
return playtimes.stream().map(playtime -> new Playtime(targets.getOrDefault(playtime.appid(), playtime.appid()), return playtimes.stream().map(playtime -> new Playtime(targets.getOrDefault(playtime.appid(), playtime.appid()),
playtime.minutes(), playtime.lastPlayedAt())).toList(); playtime.minutes(), playtime.lastPlayedAt(), playtime.played())).toList();
} }
// Several titles can be one game (PS4 and PS5 versions, a review pick): its playtime is their sum, so the row // Several titles can be one game (PS4 and PS5 versions, a review pick): its playtime is their sum, so the row
@@ -205,33 +223,35 @@ public class PsnSyncStep {
byGame.merge(playtime.appid(), playtime, (a, b) -> new Playtime(a.appid(), byGame.merge(playtime.appid(), playtime, (a, b) -> new Playtime(a.appid(),
(int) Math.min((long) a.minutes() + b.minutes(), Integer.MAX_VALUE), (int) Math.min((long) a.minutes() + b.minutes(), Integer.MAX_VALUE),
a.lastPlayedAt() == null || (b.lastPlayedAt() != null && b.lastPlayedAt().isAfter(a.lastPlayedAt())) a.lastPlayedAt() == null || (b.lastPlayedAt() != null && b.lastPlayedAt().isAfter(a.lastPlayedAt()))
? b.lastPlayedAt() : a.lastPlayedAt())); ? b.lastPlayedAt() : a.lastPlayedAt(),
(int) Math.min((long) a.played() + b.played(), Integer.MAX_VALUE)));
} }
return byGame.values(); return byGame.values();
} }
/** /**
* In the sync transaction, after the Steam games are saved: playtime, last played and two-weeks minutes of the * In the sync transaction, after the Steam games are saved: playtime, last played and two-weeks minutes of the
* PSN rows. No status change: PSN rows have statusSetByUser. * PSN rows. The played minutes of a game are booked on the day it was last played, in the given zone. No status
* change: PSN rows have statusSetByUser.
* *
* @return all PSN changes of the run (ADDED and PLAYTIME) * @return all PSN changes of the run (ADDED and PLAYTIME)
*/ */
public List<SyncChange> apply(Long userId, Prepared prepared, Instant now, LocalDate day) { public List<SyncChange> apply(Long userId, Prepared prepared, Instant now, LocalDate day, ZoneId zone) {
List<SyncChange> changes = new ArrayList<>(prepared.changes()); List<SyncChange> changes = new ArrayList<>(prepared.changes());
Map<Long, UserGame> touched = new HashMap<>(); Map<Long, UserGame> touched = new HashMap<>();
for (Playtime playtime : perGame(prepared.playtimes())) { for (Playtime playtime : perGame(prepared.playtimes())) {
userGameRepository.findById(new UserGameId(userId, playtime.appid())) userGameRepository.findById(new UserGameId(userId, playtime.appid()))
.filter(game -> game.getSource() == GameSource.PSN && game.getRemovedAt() == null) .filter(game -> game.getSource() == GameSource.PSN && game.getRemovedAt() == null)
.ifPresent(game -> { .ifPresent(game -> {
int growth = playtime.minutes() - game.getPlaytimeMinutes();
game.setPlaytimeMinutes(playtime.minutes()); game.setPlaytimeMinutes(playtime.minutes());
game.setLastSeenAt(now); game.setLastSeenAt(now);
if (playtime.lastPlayedAt() != null) { if (playtime.lastPlayedAt() != null) {
game.setLastPlayedAt(playtime.lastPlayedAt()); game.setLastPlayedAt(playtime.lastPlayedAt());
} }
if (growth > 0) { if (playtime.played() > 0) {
changes.add(SyncChange.playtime(game.getAppid(), growth)); LocalDate playedOn = LocalDate.ofInstant(playtime.lastPlayedAt(), zone);
playtimeDayRepository.addMinutes(userId, game.getAppid(), day, growth); changes.add(SyncChange.playtime(game.getAppid(), playtime.played()));
playtimeDayRepository.addMinutes(userId, game.getAppid(), playedOn, playtime.played());
} }
touched.put(game.getAppid(), game); touched.put(game.getAppid(), game);
}); });
@@ -243,6 +263,9 @@ public class PsnSyncStep {
touched.forEach((appid, game) -> touched.forEach((appid, game) ->
game.setPlaytime2Weeks((int) Math.min(twoWeeks.getOrDefault(appid, 0L), Integer.MAX_VALUE))); game.setPlaytime2Weeks((int) Math.min(twoWeeks.getOrDefault(appid, 0L), Integer.MAX_VALUE)));
} }
if (!prepared.plays().isEmpty()) {
psnTitleRepository.savePlays(userId, prepared.plays());
}
return changes; return changes;
} }
} }
@@ -0,0 +1,16 @@
package core_service.shared.sql;
import java.util.Locale;
public final class LikePattern {
private LikePattern() {}
/**
* Lower-cases the text and escapes {@code \}, {@code %} and {@code _}, so a {@code like ... escape '\'} matches
* them literally. The caller adds the wildcards.
*/
public static String escapeLower(String text) {
return text.toLowerCase(Locale.ROOT).replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
}
}
+8
View File
@@ -8,6 +8,12 @@ spring:
accept-float-as-int: false accept-float-as-int: false
jpa: jpa:
open-in-view: false open-in-view: false
datasource:
hikari:
# Plan once, not per request: Dashboard burst 443 -> 229 ms, 94% of the DB time was planning (#49).
data-source-properties:
prepareThreshold: 1
connection-init-sql: set plan_cache_mode = force_generic_plan
flyway: flyway:
enabled: true enabled: true
baseline-on-migrate: true baseline-on-migrate: true
@@ -100,6 +106,8 @@ app:
# The hand changes of a followed user are one batch after this quiet time. # The hand changes of a followed user are one batch after this quiet time.
digest-ms: 600000 digest-ms: 600000
quiet-minutes: 10 quiet-minutes: 10
# Player searches per user and minute; more answers 429.
search-per-minute: 30
inbox: inbox:
purge-cron: "0 15 3 * * *" purge-cron: "0 15 3 * * *"
keep-days: 90 keep-days: 90
@@ -0,0 +1,12 @@
-- What a DIGEST note counted (#27 follow-up). Names are snapshots; the row goes with its note.
CREATE TABLE inbox_note_detail (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES inbox_note (id) ON DELETE CASCADE,
kind VARCHAR(16) NOT NULL CHECK (kind IN ('NEW_GAME', 'ACHIEVEMENT', 'STATUS', 'WISHLIST')),
appid BIGINT NOT NULL,
game_name VARCHAR(512), -- steam_game.name / wishlist_game.name at write time
item_name VARCHAR(512), -- ACHIEVEMENT: achievement or trophy name
from_status VARCHAR(16), -- STATUS only
to_status VARCHAR(16)
);
CREATE INDEX idx_inbox_note_detail_note ON inbox_note_detail (note_id);
@@ -0,0 +1,11 @@
-- Gitea #53: the PSN sync booked the whole history of a newly linked title on the sync day (24 h of a game last
-- played in 2025). A PSN row's day row more than one day after the day its titles were last played is no real
-- play: drop it. One day is kept: a sync after midnight booked the evening before on the next day.
-- Titles without last_played_at are left alone. Safe to run again.
DELETE FROM user_game_playtime_day p
USING user_game ug,
(SELECT user_id, appid, MAX(last_played_at) AS last_played_at FROM psn_title_play
GROUP BY user_id, appid) t
WHERE ug.user_id = p.user_id AND ug.appid = p.appid AND ug.source = 'PSN'
AND t.user_id = ug.user_id AND t.appid = ug.appid
AND p.day > (t.last_played_at AT TIME ZONE 'Europe/Berlin')::date + 1;
@@ -0,0 +1,28 @@
package core_service.configuration;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.assertj.core.api.Assertions.assertThat;
class DataSourceSettingsTest extends AbstractPersistenceTest {
@Autowired
private JdbcTemplate jdbc;
@Test
void coreConnection_ForcesGenericPlans() {
assertThat(jdbc.queryForObject("show plan_cache_mode", String.class)).isEqualTo("force_generic_plan");
}
@Test
void coreConnection_PreparesOnTheFirstUse() {
jdbc.queryForObject("select 1 where ? = 1", Integer.class, 1);
assertThat(jdbc.queryForObject(
"select count(*) from pg_prepared_statements where statement like 'select 1 where%'", Integer.class))
.isEqualTo(1);
}
}
@@ -68,14 +68,19 @@ class LocalMocksParseTest extends AbstractUnitTest {
@Test @Test
void everyMapping_PointsToAnExistingAnswer() throws IOException { void everyMapping_PointsToAnExistingAnswer() throws IOException {
List<Path> mappings; List<Path> mappings = sortedFiles(MAPPINGS);
try (Stream<Path> listed = Files.list(MAPPINGS)) {
mappings = listed.sorted().toList();
}
assertThat(mappings).isNotEmpty(); assertThat(mappings).isNotEmpty();
for (Path mapping : mappings) { for (Path mapping : mappings) {
JsonNode response = jsonMapper.readTree(mapping.toFile()).path("response"); JsonNode root = jsonMapper.readTree(mapping.toFile());
JsonNode response = root.path("response");
// A copied achievement mapping must ask for the appid in its own name.
String name = mapping.getFileName().toString();
if (name.matches("steam-(schema|player-achievements|global-percentages)-\\d+\\.json")) {
JsonNode query = root.path("request").path("queryParameters");
String asked = query.path(query.has("appid") ? "appid" : "gameid").path("equalTo").asString();
assertThat(name).as(mapping.toString()).isEqualTo(name.replaceAll("\\d+", asked));
}
// The PlayStation authorize answer is a redirect with no body. // The PlayStation authorize answer is a redirect with no body.
assertThat(response.has("bodyFileName") || response.has("jsonBody") || response.path("headers").has("Location")) assertThat(response.has("bodyFileName") || response.has("jsonBody") || response.path("headers").has("Location"))
.as(mapping.toString()).isTrue(); .as(mapping.toString()).isTrue();
@@ -105,10 +110,7 @@ class LocalMocksParseTest extends AbstractUnitTest {
@Test @Test
void everyRecordedAnswer_ParsesIntoItsResponseType() throws IOException, ReflectiveOperationException { void everyRecordedAnswer_ParsesIntoItsResponseType() throws IOException, ReflectiveOperationException {
List<Path> files; List<Path> files = sortedFiles(FILES);
try (Stream<Path> listed = Files.list(FILES)) {
files = listed.sorted().toList();
}
assertThat(files).isNotEmpty(); assertThat(files).isNotEmpty();
for (Path file : files) { for (Path file : files) {
@@ -121,4 +123,30 @@ class LocalMocksParseTest extends AbstractUnitTest {
assertFilled(name, jsonMapper.readValue(file.toFile(), type)); assertFilled(name, jsonMapper.readValue(file.toFile(), type));
} }
} }
// A copied stub with a typo in its appid is otherwise only a silent 400 in the sync log.
@Test
void everyAchievementStub_BelongsToAnOwnedGame() throws IOException {
List<String> owned = jsonMapper.readValue(FILES.resolve("steam-owned-games.json").toFile(), SteamOwnedGamesResponse.class)
.response().games().stream()
.map(game -> game.appid().toString())
.toList();
List<Path> schemas = sortedFiles(FILES).stream()
.filter(file -> file.getFileName().toString().matches("steam-schema-\\d+\\.json"))
.toList();
assertThat(schemas).isNotEmpty();
for (Path schema : schemas) {
String appid = schema.getFileName().toString().replaceAll("\\D", "");
assertThat(owned).as(schema.toString()).contains(appid);
assertThat(FILES.resolve("steam-player-achievements-" + appid + ".json")).exists();
assertThat(FILES.resolve("steam-global-percentages-" + appid + ".json")).exists();
}
}
private static List<Path> sortedFiles(Path dir) throws IOException {
try (Stream<Path> listed = Files.list(dir)) {
return listed.sorted().toList();
}
}
} }
@@ -14,6 +14,7 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilde
import tools.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper; import tools.jackson.databind.json.JsonMapper;
import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@@ -62,6 +63,7 @@ class FollowControllerTest extends AbstractIntegrationTest {
@AfterEach @AfterEach
void cleanUp() { void cleanUp() {
jdbc.update("delete from follow"); jdbc.update("delete from follow");
jdbc.update("delete from inbox_note");
jdbc.update("delete from follow_digest"); jdbc.update("delete from follow_digest");
jdbc.update("delete from public_user"); jdbc.update("delete from public_user");
jdbc.update("delete from account_name"); jdbc.update("delete from account_name");
@@ -82,7 +84,15 @@ class FollowControllerTest extends AbstractIntegrationTest {
} }
private JsonNode list(long userId) throws Exception { private JsonNode list(long userId) throws Exception {
String body = mockMvc.perform(as(get("/api/follows"), userId)).andExpect(status().isOk()) return okJson(get("/api/follows"), userId);
}
private JsonNode players(long userId, String query) throws Exception {
return okJson(get("/api/follows/players").param("q", query), userId);
}
private JsonNode okJson(MockHttpServletRequestBuilder request, long userId) throws Exception {
String body = mockMvc.perform(as(request, userId)).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString(); .andReturn().getResponse().getContentAsString();
return JsonMapper.builder().build().readTree(body); return JsonMapper.builder().build().readTree(body);
} }
@@ -204,6 +214,95 @@ class FollowControllerTest extends AbstractIntegrationTest {
assertThat(json.toString()).doesNotContain("SecretThree", String.valueOf(LOBO), String.valueOf(SECRET)); assertThat(json.toString()).doesNotContain("SecretThree", String.valueOf(LOBO), String.valueOf(SECRET));
Set<String> keys = new HashSet<>(); Set<String> keys = new HashSet<>();
followers.get(0).propertyNames().forEach(keys::add); 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();
}
@Test
void players_FindsPublicNamesCaseInsensitiveAndNotPrivateBlockedDeleted() throws Exception {
JsonNode json = players(VIEWER, "ER");
List<String> names = new ArrayList<>();
json.get("results").forEach(player -> names.add(player.get("displayName").asString()));
assertThat(names).containsExactlyInAnyOrder("Viewer", "OtherSix");
assertThat(json.get("results").get(0).propertyNames()).containsExactly("displayName");
assertThat(players(VIEWER, "re").get("results")).isEmpty();
assertThat(players(VIEWER, "ed").get("results")).isEmpty();
}
@Test
void players_PrefixHitsComeFirst() throws Exception {
name(OTHER + 2, "Zolo");
jdbc.update("insert into public_user (user_id) values (?)", OTHER + 2);
JsonNode results = players(VIEWER, "lo").get("results");
assertThat(results.get(0).get("displayName").asString()).isEqualTo("Lobo");
assertThat(results.get(1).get("displayName").asString()).isEqualTo("Zolo");
}
@Test
void players_ShortQueryIsEmpty() throws Exception {
assertThat(players(VIEWER, "x").get("results")).isEmpty();
}
@Test
void players_EscapesLikeWildcards() throws Exception {
assertThat(players(VIEWER, "%").get("results")).isEmpty();
assertThat(players(VIEWER, "_o").get("results")).isEmpty();
}
@Test
void players_QueryLongerThan100_Is400() throws Exception {
mockMvc.perform(as(get("/api/follows/players").param("q", "x".repeat(101)), VIEWER))
.andExpect(status().isBadRequest());
}
@Test
void players_WithoutLogin_Is401() throws Exception {
mockMvc.perform(get("/api/follows/players").param("q", "lo")).andExpect(status().isUnauthorized());
}
@Test
void players_31stSearchInAMinute_Is429() throws Exception {
long searcher = 950_001L;
for (int i = 0; i < 30; i++) {
mockMvc.perform(as(get("/api/follows/players").param("q", "lo"), searcher)).andExpect(status().isOk());
}
mockMvc.perform(as(get("/api/follows/players").param("q", "lo"), searcher))
.andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.code").value("TOO_MANY_SEARCHES"));
} }
} }
@@ -0,0 +1,65 @@
package core_service.modules.follow.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class DigestItemsTest extends AbstractUnitTest {
private static DigestItems of(DigestItem... items) {
return new DigestItems(List.of(items));
}
private static final DigestItems ALL = of(DigestItem.newGame(1, "A"), DigestItem.achievement(1, "A", "Win"),
DigestItem.status(2, "B", "BACKLOG", "PLAYING"), DigestItem.wishlist(3, "C"));
@Test
void collapseStatus_FirstFromToLastTo() {
DigestItems items = of(DigestItem.status(1, "A", "BACKLOG", "PLAYING"), DigestItem.status(1, "A", "PLAYING", "FINISHED"));
assertThat(items.collapseStatus().items()).containsExactly(DigestItem.status(1, "A", "BACKLOG", "FINISHED"));
}
@Test
void collapseStatus_DropsAGameThatEndsWhereItStarted() {
DigestItems items = of(DigestItem.status(1, "A", "BACKLOG", "PLAYING"), DigestItem.status(1, "A", "PLAYING", "BACKLOG"));
assertThat(items.collapseStatus().items()).isEmpty();
}
@Test
void collapseStatus_KeepsGamesApartAndOtherKindsAndOrder() {
DigestItems items = of(DigestItem.newGame(9, "N"), DigestItem.status(1, "A", "BACKLOG", "PLAYING"),
DigestItem.status(2, "B", "BACKLOG", "PLAYING"), DigestItem.status(1, "A", "PLAYING", "FINISHED"));
assertThat(items.collapseStatus().items()).containsExactly(DigestItem.newGame(9, "N"),
DigestItem.status(1, "A", "BACKLOG", "FINISHED"), DigestItem.status(2, "B", "BACKLOG", "PLAYING"));
}
@Test
void visibleOn_NullKeepsAll() {
assertThat(ALL.visibleOn(null)).isEqualTo(ALL);
}
@Test
void visibleOn_StatusSectionDropsNewGamesAndAchievementsAndKeepsWishlist() {
assertThat(ALL.visibleOn(Set.of("STATUS")).items()).extracting(DigestItem::kind)
.containsExactly(DigestItem.STATUS, DigestItem.WISHLIST);
}
@Test
void visibleOn_SummaryKeepsNewGamesAndAchievementsButNotStatus() {
assertThat(ALL.visibleOn(Set.of("SUMMARY")).items()).extracting(DigestItem::kind)
.containsExactly(DigestItem.NEW_GAME, DigestItem.ACHIEVEMENT, DigestItem.WISHLIST);
}
@Test
void plus_KeepsReceiverFirst() {
assertThat(of(DigestItem.wishlist(1, "A")).plus(of(DigestItem.wishlist(2, "B"))).items())
.extracting(DigestItem::appid).containsExactly(1L, 2L);
}
}
@@ -1,6 +1,8 @@
package core_service.modules.follow.repo; package core_service.modules.follow.repo;
import core_service.modules.follow.domain.Digest; import core_service.modules.follow.domain.Digest;
import core_service.modules.follow.domain.DigestItem;
import core_service.modules.follow.domain.DigestItems;
import core_service.shared.test.AbstractPersistenceTest; import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -8,6 +10,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@Import(FollowDigestRepository.class) @Import(FollowDigestRepository.class)
@@ -27,7 +31,7 @@ class FollowDigestRepositoryTest extends AbstractPersistenceTest {
void seed() { void seed() {
jdbc.update("insert into sync_result (user_id, sync_started_at, started_by, imported_games) values (?, now(), 'AUTO', 0)", USER); jdbc.update("insert into sync_result (user_id, sync_started_at, started_by, imported_games) values (?, now(), 'AUTO', 0)", USER);
for (long appid : new long[]{LISTED, REMOVED}) { for (long appid : new long[]{LISTED, REMOVED}) {
jdbc.update("insert into steam_game (appid, name) values (?, 'Game')", appid); jdbc.update("insert into steam_game (appid, name) values (?, ?)", appid, "Game " + appid);
jdbc.update("insert into user_game (user_id, appid, status, source, platform, status_set_by_user) " jdbc.update("insert into user_game (user_id, appid, status, source, platform, status_set_by_user) "
+ "values (?, ?, 'BACKLOG', 'MANUAL', 'PC', true)", USER, appid); + "values (?, ?, 'BACKLOG', 'MANUAL', 'PC', true)", USER, appid);
jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, 'ADDED', ?, null)", USER, appid); jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, 'ADDED', ?, null)", USER, appid);
@@ -49,4 +53,81 @@ class FollowDigestRepositoryTest extends AbstractPersistenceTest {
assertThat(digest.newGames()).isEqualTo(1); assertThat(digest.newGames()).isEqualTo(1);
} }
private void unlock(long appid, String apiName, String displayName, int daysAgo) {
jdbc.update("insert into game_achievement_schema (appid, api_name, display_name) values (?, ?, ?)", appid, apiName, displayName);
jdbc.update("insert into user_game_achievement (user_id, appid, api_name, unlocked, unlocked_at) "
+ "values (?, ?, ?, true, now() - make_interval(days => ?))", USER, appid, apiName, daysAgo);
}
@Test
void findSyncItems_SkipsRemovedGamesAndNamesTheGame() {
DigestItems items = repository.findSyncItems(USER);
assertThat(items.items()).contains(DigestItem.newGame(LISTED, "Game 10"));
assertThat(items.items()).noneMatch(item -> item.appid() == REMOVED);
}
@Test
void findSyncItems_TakesTheAmountNewestUnlocks() {
unlock(LISTED, "a", "Oldest", 40);
unlock(LISTED, "b", "Third", 30);
unlock(LISTED, "c", "Second", 20);
unlock(LISTED, "d", "Newest", 10);
unlock(REMOVED, "e", "Removed one", 1);
List<DigestItem> achievements = repository.findSyncItems(USER).items().stream()
.filter(item -> DigestItem.ACHIEVEMENT.equals(item.kind())).toList();
assertThat(achievements).containsExactly(DigestItem.achievement(LISTED, "Game 10", "Newest"),
DigestItem.achievement(LISTED, "Game 10", "Second"), DigestItem.achievement(LISTED, "Game 10", "Third"));
}
@Test
void findSyncItems_PsnTrophiesCompeteWithSteamUnlocksOfTheSameGame() {
unlock(LISTED, "a", "Old steam", 40);
jdbc.update("insert into psn_trophy_set (np_communication_id, service_name, platform, name, defined_bronze, "
+ "defined_silver, defined_gold, defined_platinum) values ('NPWR1', 'trophy', 'PS4', 'T', 1, 0, 0, 0)");
jdbc.update("insert into psn_trophy (np_communication_id, trophy_id, type, name) values ('NPWR1', 1, 'BRONZE', 'Trophy one')");
jdbc.update("insert into psn_trophy (np_communication_id, trophy_id, type, name) values ('NPWR1', 2, 'BRONZE', null)");
jdbc.update("insert into user_psn_trophy_set (user_id, np_communication_id, appid, earned_total, platinum_earned) "
+ "values (?, 'NPWR1', ?, 2, false)", USER, LISTED);
jdbc.update("insert into user_psn_trophy (user_id, np_communication_id, trophy_id, earned_at) values (?, 'NPWR1', 1, now() - interval '1 day')", USER);
jdbc.update("insert into user_psn_trophy (user_id, np_communication_id, trophy_id, earned_at) values (?, 'NPWR1', 2, now() - interval '2 days')", USER);
List<String> names = repository.findSyncItems(USER).items().stream()
.filter(item -> DigestItem.ACHIEVEMENT.equals(item.kind())).map(DigestItem::itemName).toList();
assertThat(names).containsExactly("Trophy one", "Trophy 2", "Old steam");
}
@Test
void findWishlistItems_UsesTheWishlistName() {
jdbc.update("insert into wishlist_game (user_id, appid, name) values (?, 777, 'Wished')", USER);
jdbc.update("insert into sync_change (user_id, kind, appid) values (?, 'WISHLIST', 777)", USER);
assertThat(repository.findWishlistItems(USER, 0, Long.MAX_VALUE).items())
.containsExactly(DigestItem.wishlist(777, "Wished"));
}
@Test
void findHandItems_NoOldStatusIsANewGame() {
assertThat(repository.findHandItems(USER, 0, Long.MAX_VALUE, false).items())
.containsExactly(DigestItem.newGame(LISTED, "Game 10"));
}
@Test
void insertDigestNotesAndItems_ReturnIdsAndWriteRowsPerNote() {
jdbc.update("insert into public_user (user_id) values (?)", USER);
jdbc.update("insert into follow (user_id, followed_user_id) values (2, ?), (3, ?)", USER, USER);
List<Long> ids = repository.insertDigestNotes(USER, new Digest(1, 0, 0, 0));
repository.insertDigestItems(ids, new DigestItems(List.of(DigestItem.newGame(LISTED, "Game 10"),
DigestItem.wishlist(5, "W"))));
assertThat(ids).hasSize(2);
assertThat(jdbc.queryForObject("select count(*) from inbox_note_detail", Integer.class)).isEqualTo(4);
assertThat(jdbc.queryForObject("select count(*) from inbox_note_detail where note_id = ?", Integer.class, ids.get(0)))
.isEqualTo(2);
}
} }
@@ -79,6 +79,13 @@ class FollowDigestServiceTest extends AbstractIntegrationTest {
jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, ?, ?, ?)", FOLLOWED, kind, appid, amount); jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, ?, ?, ?)", FOLLOWED, kind, appid, amount);
} }
private void moved(long appid) {
jdbc.update("insert into sync_change (user_id, kind, appid, from_status, to_status) values (?, 'STATUS', ?, 'BACKLOG', 'PLAYING')",
FOLLOWED, appid);
jdbc.update("insert into user_game_status_change (user_id, appid, from_status, to_status, by_user) values (?, ?, 'BACKLOG', 'PLAYING', false)",
FOLLOWED, appid);
}
private void added(int count) { private void added(int count) {
games(count); games(count);
sync(); sync();
@@ -131,6 +138,87 @@ class FollowDigestServiceTest extends AbstractIntegrationTest {
assertThat(jdbc.queryForObject("select subject_name from inbox_note limit 1", String.class)).isEqualTo("Followed"); assertThat(jdbc.queryForObject("select subject_name from inbox_note limit 1", String.class)).isEqualTo("Followed");
} }
private List<Map<String, Object>> details(long reader) {
return jdbc.queryForList("select d.kind, d.game_name, d.item_name, d.from_status, d.to_status from inbox_note_detail d "
+ "join inbox_note n on n.id = d.note_id where n.user_id = ? order by d.id", reader);
}
@Test
void syncBatch_WritesTheLinesBehindTheCountsForEachFollower() {
added(2);
moved(FIRST_APPID + 1);
change("WISHLIST", 777_001L, null);
event();
for (long reader : List.of(FOLLOWER_ONE, FOLLOWER_TWO)) {
assertThat(details(reader)).extracting(row -> row.get("kind"))
.containsExactlyInAnyOrder("NEW_GAME", "NEW_GAME", "STATUS", "WISHLIST");
assertThat(details(reader)).anySatisfy(row -> assertThat(row).containsEntry("game_name", "Game 1"));
}
}
@Test
void handChangesThereAndBack_WriteNoStatusLineButTheCountStays() {
handGame(920_001L, 30);
jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid = ?", FOLLOWED, 920_001L);
jdbc.update("update user_game set status = 'BACKLOG' where user_id = ? and appid = ?", FOLLOWED, 920_001L);
jdbc.update("update user_game_status_change set changed_at = now() - interval '30 minutes' where user_id = ?", FOLLOWED);
service.digestHandChanges();
assertThat(notes().get(0)).containsEntry("status_changes", 2);
assertThat(details(FOLLOWER_ONE)).extracting(row -> row.get("kind")).doesNotContain("STATUS");
}
@Test
void handMoveBeforeSyncMoveGivesOneLineFromFirstToLast() {
handGame(920_001L, 30);
jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid = ?", FOLLOWED, 920_001L);
sync();
jdbc.update("insert into sync_change (user_id, kind, appid, from_status, to_status) values (?, 'STATUS', ?, 'PLAYING', 'FINISHED')",
FOLLOWED, 920_001L);
jdbc.update("insert into user_game_status_change (user_id, appid, from_status, to_status, by_user) values (?, ?, 'PLAYING', 'FINISHED', false)",
FOLLOWED, 920_001L);
event();
assertThat(notes().get(0)).containsEntry("status_changes", 2);
assertThat(details(FOLLOWER_ONE)).filteredOn(row -> "STATUS".equals(row.get("kind")))
.extracting(row -> row.get("from_status"), row -> row.get("to_status"))
.containsExactly(org.assertj.core.groups.Tuple.tuple("BACKLOG", "FINISHED"));
}
@Test
void syncMoveBeforeHandMoveGivesOneLineFromFirstToLast() {
handGame(920_001L, 30);
jdbc.update("delete from user_game_status_change where user_id = ? and appid = ?", FOLLOWED, 920_001L);
sync();
jdbc.update("update user_game set status = 'PLAYING', status_set_by_user = false where user_id = ? and appid = ?", FOLLOWED, 920_001L);
jdbc.update("insert into sync_change (user_id, kind, appid, from_status, to_status) values (?, 'STATUS', ?, 'BACKLOG', 'PLAYING')",
FOLLOWED, 920_001L);
jdbc.update("update user_game set status = 'FINISHED', status_set_by_user = true where user_id = ? and appid = ?", FOLLOWED, 920_001L);
event();
assertThat(notes().get(0)).containsEntry("status_changes", 2);
assertThat(details(FOLLOWER_ONE)).filteredOn(row -> "STATUS".equals(row.get("kind")))
.extracting(row -> row.get("from_status"), row -> row.get("to_status"))
.containsExactly(org.assertj.core.groups.Tuple.tuple("BACKLOG", "FINISHED"));
}
@Test
void statusSectionOnly_WritesNoNewGameOrAchievementLines() {
added(2);
change("ACHIEVEMENTS", FIRST_APPID + 1, 3);
moved(FIRST_APPID + 1);
sections("STATUS");
event();
assertThat(details(FOLLOWER_ONE)).extracting(row -> row.get("kind")).containsExactly("STATUS");
}
@Test @Test
void noActivity_WritesNoNoteButMovesTheCursor() { void noActivity_WritesNoNoteButMovesTheCursor() {
sync(); sync();
@@ -351,4 +439,20 @@ class FollowDigestServiceTest extends AbstractIntegrationTest {
assertThat(jdbc.queryForObject("select count(*) from inbox_note where achievements = 7 and new_games = 2", assertThat(jdbc.queryForObject("select count(*) from inbox_note where achievements = 7 and new_games = 2",
Integer.class)).isEqualTo(2); Integer.class)).isEqualTo(2);
} }
@Test
void handJobBeforeSync_LeavesTheSyncStatusRowsForTheSyncDigest() {
handGame(920_001L, 30);
added(1);
moved(FIRST_APPID + 1);
service.digestHandChanges();
event();
assertThat(details(FOLLOWER_ONE)).extracting(row -> row.get("kind"), row -> row.get("game_name"))
.containsExactlyInAnyOrder(org.assertj.core.groups.Tuple.tuple("NEW_GAME", "Hand"),
org.assertj.core.groups.Tuple.tuple("NEW_GAME", "Game 1"),
org.assertj.core.groups.Tuple.tuple("STATUS", "Game 1"));
assertThat(notes()).extracting(note -> note.get("status_changes")).containsOnly(0, 1);
}
} }
@@ -0,0 +1,19 @@
package core_service.modules.follow.service;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PlayerSearchLimiterTest extends AbstractUnitTest {
@Test
void allow_RefusesTheSearchAfterTheLimitAndCountsEachUserOnItsOwn() {
PlayerSearchLimiter limiter = new PlayerSearchLimiter(2);
assertThat(limiter.allow(1)).isTrue();
assertThat(limiter.allow(1)).isTrue();
assertThat(limiter.allow(1)).isFalse();
assertThat(limiter.allow(2)).isTrue();
}
}
@@ -35,6 +35,25 @@ class InboxControllerTest extends AbstractIntegrationTest {
@AfterEach @AfterEach
void cleanUp() { void cleanUp() {
jdbc.update("delete from inbox_note"); 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");
}
/** A note of READER with lines, the subject (7) public and followed. */
private long noteWithDetails() {
note(READER, "Subject", 1, 2);
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), "
+ "(?, 'STATUS', 502, 'Beta', null, 'BACKLOG', 'PLAYING')", id, id, id);
return id;
} }
private static MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId) { private static MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId) {
@@ -65,6 +84,7 @@ class InboxControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.results[0].kind").value("DIGEST")) .andExpect(jsonPath("$.results[0].kind").value("DIGEST"))
.andExpect(jsonPath("$.results[0].newGames").value(2)) .andExpect(jsonPath("$.results[0].newGames").value(2))
.andExpect(jsonPath("$.results[0].read").value(false)) .andExpect(jsonPath("$.results[0].read").value(false))
.andExpect(jsonPath("$.results[0].profileName").doesNotExist())
.andExpect(jsonPath("$.results[1].displayName").value("Older")) .andExpect(jsonPath("$.results[1].displayName").value("Older"))
.andExpect(jsonPath("$.unread").value(2)) .andExpect(jsonPath("$.unread").value(2))
.andReturn().getResponse().getContentAsString(); .andReturn().getResponse().getContentAsString();
@@ -95,4 +115,65 @@ class InboxControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.results[0].read").value(true)); .andExpect(jsonPath("$.results[0].read").value(true));
mockMvc.perform(as(get("/api/inbox"), OTHER)).andExpect(jsonPath("$.unread").value(1)); mockMvc.perform(as(get("/api/inbox"), OTHER)).andExpect(jsonPath("$.unread").value(1));
} }
@Test
void details_OfAnOwnNoteListsTheLinesWithoutIdentifiers() throws Exception {
long id = noteWithDetails();
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("subject", "userId");
mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(true))
.andExpect(jsonPath("$.results[0].profileName").value("SubjectNow"));
}
@Test
void details_OfAnotherReadersNoteIs404() throws Exception {
long id = noteWithDetails();
mockMvc.perform(as(get("/api/inbox/" + id + "/details"), OTHER)).andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").doesNotExist());
}
@Test
void details_AfterUnfollowIs404AndTheListShowsNoDetails() throws Exception {
long id = noteWithDetails();
jdbc.update("delete from follow where user_id = ?", READER);
mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isNotFound());
mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(false));
}
@Test
void details_OnlyKindsOfTheSectionsThatAreStillPublic() throws Exception {
long id = noteWithDetails();
jdbc.update("update public_user set sections = '{STATUS}' where user_id = 7");
mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk())
.andExpect(jsonPath("$.results.length()").value(1))
.andExpect(jsonPath("$.results[0].kind").value("STATUS"));
}
@Test
void details_LeaveOutAGameTheSubjectHidesNow() throws Exception {
long id = noteWithDetails();
jdbc.update("insert into steam_game (appid, name) values (501, 'Alpha') on conflict do nothing");
jdbc.update("insert into user_game (user_id, appid, status, hidden_at) values (7, 501, 'BACKLOG', now())");
mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk())
.andExpect(jsonPath("$.results.length()").value(1))
.andExpect(jsonPath("$.results[0].gameName").value("Beta"));
}
@Test
void details_WithoutLoginIs401() throws Exception {
mockMvc.perform(get("/api/inbox/1/details")).andExpect(status().isUnauthorized());
}
} }
@@ -0,0 +1,44 @@
package core_service.modules.inbox.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class NoteDetailKindTest extends AbstractUnitTest {
@Test
void visibleOn_NullSectionsShowsEverything() {
for (NoteDetailKind kind : NoteDetailKind.values()) {
assertThat(kind.visibleOn(null)).as(kind.name()).isTrue();
}
}
@Test
void visibleOn_SummaryShowsGamesAndAchievementsOnly() {
Set<String> sections = Set.of("SUMMARY");
assertThat(NoteDetailKind.NEW_GAME.visibleOn(sections)).isTrue();
assertThat(NoteDetailKind.ACHIEVEMENT.visibleOn(sections)).isTrue();
assertThat(NoteDetailKind.STATUS.visibleOn(sections)).isFalse();
assertThat(NoteDetailKind.WISHLIST.visibleOn(sections)).isTrue();
}
@Test
void visibleOn_EachStatusSectionShowsStatusButNotGames() {
for (String section : new String[]{"STATUS", "PLAYING", "FINISHED"}) {
Set<String> sections = Set.of(section);
assertThat(NoteDetailKind.STATUS.visibleOn(sections)).as(section).isTrue();
assertThat(NoteDetailKind.NEW_GAME.visibleOn(sections)).as(section).isFalse();
assertThat(NoteDetailKind.ACHIEVEMENT.visibleOn(sections)).as(section).isFalse();
}
}
@Test
void visibleOn_OnlyStatsLeavesTheWishlist() {
assertThat(NoteDetailKind.WISHLIST.visibleOn(Set.of("STATS"))).isTrue();
assertThat(NoteDetailKind.STATUS.visibleOn(Set.of("STATS"))).isFalse();
}
}
@@ -1,5 +1,6 @@
package core_service.modules.inbox.repo; package core_service.modules.inbox.repo;
import core_service.modules.inbox.domain.NoteDetail;
import core_service.shared.test.AbstractPersistenceTest; import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -43,4 +44,64 @@ class InboxRepositoryTest extends AbstractPersistenceTest {
assertThat(deleted).isEqualTo(2); assertThat(deleted).isEqualTo(2);
assertThat(ids()).containsExactly(3L, 4L); assertThat(ids()).containsExactly(3L, 4L);
} }
@Test
void deleteBefore_AlsoRemovesTheDetailRows() {
jdbc.update("delete from inbox_note");
note(1, 91, true);
jdbc.update("insert into inbox_note_detail (note_id, kind, appid) values (1, 'NEW_GAME', 5)");
repository.deleteBefore(Instant.now().minus(Duration.ofDays(90)));
assertThat(jdbc.queryForObject("select count(*) from inbox_note_detail", Integer.class)).isZero();
}
@Test
void findNewest_DetailsNeedRowsAndAFollowAndAPublicSubject() {
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.findNewest(1, 10).get(0).details()).isFalse();
jdbc.update("insert into follow (user_id, followed_user_id) values (1, 2)");
assertThat(repository.findNewest(1, 10).get(0).details()).isFalse();
jdbc.update("insert into public_user (user_id) values (2)");
assertThat(repository.findNewest(1, 10).get(0).details()).isTrue();
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());
}
}
@@ -91,6 +91,16 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.recentGames[1].imageUrl").doesNotExist()); .andExpect(jsonPath("$.recentGames[1].imageUrl").doesNotExist());
} }
@Test
void activity_ADayCountsAtMost24Hours() throws Exception {
playtimeDayRepository.addMinutes(USER_ID, 220L, today, 1440);
playtimeDayRepository.addMinutes(USER_ID, 620L, today, 1440);
mockMvc.perform(get("/api/library/activity").param("days", "7"))
.andExpect(jsonPath("$.days[6].minutes").value(1440))
.andExpect(jsonPath("$.totalMinutes").value(1440));
}
@Test @Test
void activity_RecentGamesAreOneEntryPerSetWithTheSumOfItsEditions() throws Exception { void activity_RecentGamesAreOneEntryPerSetWithTheSumOfItsEditions() throws Exception {
jdbc.update("update user_game set playtime_2weeks = 50 where user_id = ? and appid = 700", USER_ID); jdbc.update("update user_game set playtime_2weeks = 50 where user_id = ? and appid = 700", USER_ID);
@@ -0,0 +1,69 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractMockedAdaptersTest;
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.test.web.servlet.MockMvc;
import java.time.Instant;
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;
/**
* The dev profile authenticates every request as user 1.
*/
@AutoConfigureMockMvc
class LibraryControllerSortTest extends AbstractMockedAdaptersTest {
private static final long USER_ID = 1L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now()));
steamGameRepository.save(new SteamGame(620L, "portal 2", null, Instant.now()));
steamGameRepository.save(new SteamGame(700L, "Antichamber", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 620L, 900, Instant.now(), Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 700L, 0, Instant.now(), Instant.now()));
}
@Test
void sortByName_ListsTheNamesAZ() throws Exception {
mockMvc.perform(get("/api/library/games?sort=name,asc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].appid").value(700))
.andExpect(jsonPath("$.content[1].appid").value(220))
.andExpect(jsonPath("$.content[2].appid").value(620));
}
@Test
void sortByTimeToBeatAndLastPlayed_AreAccepted() throws Exception {
mockMvc.perform(get("/api/library/games?sort=timeToBeat,asc")).andExpect(status().isOk());
mockMvc.perform(get("/api/library/games?sort=lastPlayedAt,desc")).andExpect(status().isOk());
}
@Test
void sortByLastSeenAt_StillWorks() throws Exception {
mockMvc.perform(get("/api/library/games?sort=lastSeenAt,desc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content.length()").value(3));
}
}
@@ -28,6 +28,8 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -247,13 +249,16 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
awaitSync(); awaitSync();
long importId = reviewImportId(); long importId = reviewImportId();
playing(185, Instant.parse("2026-10-06T20:00:00Z")); Instant playedAgain = Instant.now().minus(Duration.ofHours(2));
playing(185, playedAgain);
worker.runSyncNow(1L, null, SyncTrigger.AUTO); worker.runSyncNow(1L, null, SyncTrigger.AUTO);
assertThat(jdbc.queryForMap("select kind, appid, amount from sync_change where user_id = 1")) assertThat(jdbc.queryForMap("select kind, appid, amount from sync_change where user_id = 1"))
.containsEntry("kind", "PLAYTIME").containsEntry("appid", -300L).containsEntry("amount", 60); .containsEntry("kind", "PLAYTIME").containsEntry("appid", -300L).containsEntry("amount", 60);
assertThat(jdbc.queryForObject("select minutes from user_game_playtime_day where user_id = 1 and appid = -300", assertThat(jdbc.queryForObject("select minutes from user_game_playtime_day where user_id = 1 and appid = -300",
Integer.class)).isEqualTo(60); Integer.class)).isEqualTo(60);
assertThat(jdbc.queryForObject("select day from user_game_playtime_day where user_id = 1 and appid = -300",
LocalDate.class)).isEqualTo(LocalDate.ofInstant(playedAgain, ZoneId.of("Europe/Berlin")));
assertThat(jdbc.queryForMap("select playtime_minutes, playtime_2weeks from user_game where appid = -300")) assertThat(jdbc.queryForMap("select playtime_minutes, playtime_2weeks from user_game where appid = -300"))
.containsEntry("playtime_minutes", 185).containsEntry("playtime_2weeks", 60); .containsEntry("playtime_minutes", 185).containsEntry("playtime_2weeks", 60);
assertThat(jdbc.queryForObject("select count(*) from game_import", Integer.class)).isEqualTo(1); assertThat(jdbc.queryForObject("select count(*) from game_import", Integer.class)).isEqualTo(1);
@@ -416,4 +416,95 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
statGames(ticket(USER_ID), "STATUS", "NOPE").andExpect(status().isBadRequest()) statGames(ticket(USER_ID), "STATUS", "NOPE").andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BAD_ARG")); .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"));
}
} }
@@ -143,6 +143,20 @@ class GameMergeRepositoryTakeoverTest extends AbstractPersistenceTest {
.containsExactly("100:10"); .containsExactly("100:10");
} }
@Test
void rekeyUserGame_DayBothKeysHave_StaysUnder24Hours() {
game(USER, MANUAL, "MANUAL", "PC");
for (long appid : new long[]{MANUAL, STEAM}) {
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 1000)",
USER, appid);
}
repository.rekeyUserGame(USER, MANUAL, STEAM);
assertThat(jdbc.queryForList("select appid || ':' || minutes from user_game_playtime_day", String.class))
.containsExactly("100:1440");
}
@Test @Test
void rekeyUserGame_KindHeldOnTarget_StaysOnOldRef() { void rekeyUserGame_KindHeldOnTarget_StaysOnOldRef() {
game(USER, MANUAL, "MANUAL", "PC"); game(USER, MANUAL, "MANUAL", "PC");
@@ -36,7 +36,8 @@ class OwnedGamesQueryGuardTest extends AbstractPersistenceTest {
private static final String OLD_FROM = "from UserGame ug join SteamGame sg on sg.appid = ug.appid "; private static final String OLD_FROM = "from UserGame ug join SteamGame sg on sg.appid = ug.appid ";
private static final String OLD_SELECT = "select ug " + OLD_FROM private static final String OLD_SELECT = "select ug " + OLD_FROM
+ "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid "; + "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid "
+ "left join GameTimeToBeat tb on tb.appid = ug.appid ";
private static final String OLD_COUNT = "select count(ug) " + OLD_FROM; private static final String OLD_COUNT = "select count(ug) " + OLD_FROM;
private static final String OLD_WHERE = "where ug.userId = :userId and ug.removedAt is null " + private static final String OLD_WHERE = "where ug.userId = :userId and ug.removedAt is null " +
"and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " + "and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " +
@@ -55,7 +56,9 @@ class OwnedGamesQueryGuardTest extends AbstractPersistenceTest {
private static final List<Sort> SORTS = List.of( private static final List<Sort> SORTS = List.of(
Sort.by(Sort.Direction.DESC, "playtimeMinutes"), Sort.by(Sort.Direction.ASC, "playtimeMinutes"), Sort.by(Sort.Direction.DESC, "playtimeMinutes"), Sort.by(Sort.Direction.ASC, "playtimeMinutes"),
Sort.by(Sort.Direction.DESC, "lastSeenAt"), Sort.by(Sort.Direction.DESC, "firstSeenAt"), Sort.by(Sort.Direction.DESC, "lastSeenAt"), Sort.by(Sort.Direction.DESC, "firstSeenAt"),
Sort.by(Sort.Direction.DESC, "rating"), Sort.by(Sort.Direction.DESC, "priority")); Sort.by(Sort.Direction.DESC, "rating"), Sort.by(Sort.Direction.DESC, "priority"),
Sort.by(Sort.Direction.ASC, "name"), Sort.by(Sort.Direction.DESC, "lastPlayedAt"),
Sort.by(Sort.Direction.ASC, "timeToBeat"));
@Autowired @Autowired
private UserGameRepository userGameRepository; private UserGameRepository userGameRepository;
@@ -0,0 +1,71 @@
package core_service.modules.library.repo;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.init.ScriptUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* V49 runs on a database that already ran it once: the test seeds the old rows and runs the file again.
*/
class PsnDayRowsMigrationTest extends AbstractPersistenceTest {
private static final long USER = 9L;
@Autowired
private JdbcTemplate jdbc;
private void game(long appid, String source, String lastPlayedAt) {
jdbc.update("insert into steam_game (appid, name) values (?, 'Game') on conflict do nothing", appid);
jdbc.update("insert into user_game (user_id, appid, status, source, platform, playtime_minutes) "
+ "values (?, ?, 'PLAYING', ?, 'PS5', 5)", USER, appid, source);
jdbc.update("""
insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes, last_played_at)
values (?, ?, 'LINKED', ?, 'PS5', 5, ?::timestamptz)""", USER, "c" + appid, appid, lastPlayedAt);
}
private void day(long appid, String day) {
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, ?::date, ?, 60)",
USER, day, appid);
}
private void migrate() {
// The JdbcTemplate connection is the one of the test transaction; a pool connection would not see the seed.
jdbc.execute((ConnectionCallback<Void>) connection -> {
ScriptUtils.executeSqlScript(connection,
new ClassPathResource("db/migration/V49__Psn_Day_Rows_After_Last_Played.sql"));
return null;
});
}
private List<String> days(long appid) {
return jdbc.queryForList("select day::text from user_game_playtime_day where user_id = ? and appid = ? order by day",
String.class, USER, appid);
}
@Test
void psnRowsMoreThanADayAfterTheLastPlayedDay_AreDeleted() {
game(-10, "PSN", "2025-06-01T10:00:00Z");
game(-11, "STEAM", "2025-06-01T10:00:00Z");
game(-12, "PSN", null);
for (long appid : new long[]{-10, -11, -12}) {
day(appid, "2025-06-01");
day(appid, "2025-06-02");
day(appid, "2026-10-08");
}
migrate();
migrate();
assertThat(days(-10)).containsExactly("2025-06-01", "2025-06-02");
assertThat(days(-11)).containsExactly("2025-06-01", "2025-06-02", "2026-10-08");
assertThat(days(-12)).containsExactly("2025-06-01", "2025-06-02", "2026-10-08");
}
}
@@ -67,6 +67,15 @@ class PsnTitleRepositoryTest extends AbstractPersistenceTest {
Map.entry("t2", new PsnTitle("t2", PsnTitle.State.DISMISSED, null))); Map.entry("t2", new PsnTitle("t2", PsnTitle.State.DISMISSED, null)));
} }
@Test
void findAll_ReadsTheStoredPlay() {
Instant played = Instant.parse("2026-10-05T18:00:00Z");
repository.link(8L, "c1", -1L);
repository.savePlays(8L, List.of(new TitlePlay("c1", null, "PS5", 120, played)));
assertThat(repository.findAll(8L)).containsEntry("c1", new PsnTitle("c1", PsnTitle.State.LINKED, -1L, 120, played));
}
@Test @Test
void findAll_ReturnsOnlyTheUsersTitles() { void findAll_ReturnsOnlyTheUsersTitles() {
repository.link(3L, "c1", -1L); repository.link(3L, "c1", -1L);
@@ -348,6 +348,35 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(List.of(220L, 700L, 380L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "priority"))); assertEquals(List.of(220L, 700L, 380L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "priority")));
} }
@Test
void querySort_NameAsc_IsCaseInsensitiveAZ() {
steamGameRepository.saveAndFlush(new SteamGame(620L, "portal 2", null, Instant.now()));
assertEquals(List.of(700L, 220L, 380L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "name")));
assertEquals(List.of(620L, 380L, 220L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "name")));
}
@Test
void querySort_LastPlayedDesc_PutsNeverPlayedLast() {
Instant now = Instant.now();
userGameRepository.setLastPlayedAt(USER_ID, 380L, now.minusSeconds(3600));
userGameRepository.setLastPlayedAt(USER_ID, 700L, now);
assertEquals(List.of(700L, 380L, 220L, 620L), sortedAppids(Sort.by(Sort.Direction.DESC, "lastPlayedAt")));
assertEquals(List.of(380L, 700L, 220L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "lastPlayedAt")));
}
@Test
void querySort_TimeToBeatAsc_PutsUnknownLast() {
gameTimeToBeatRepository.saveAll(List.of(
new GameTimeToBeat(620L, 300, 480, 900, Instant.now()),
new GameTimeToBeat(220L, 500, 700, 1000, Instant.now()),
new GameTimeToBeat(380L, 100, null, 200, Instant.now())));
assertEquals(List.of(620L, 220L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.ASC, "timeToBeat")));
assertEquals(List.of(220L, 620L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "timeToBeat")));
}
private void setBacklog(long appid, Integer rating, GamePriority priority) { private void setBacklog(long appid, Integer rating, GamePriority priority) {
UserGame game = userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow(); UserGame game = userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow();
game.setRating(rating); game.setRating(rating);
@@ -262,6 +262,18 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
new UserStats.TagCount("ACTION", 1, 300)); new UserStats.TagCount("ACTION", 1, 300));
} }
@Test
void longestDay_IsCappedAt24Hours() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.PLAYING, Instant.now()),
game(620L, 900, GameStatus.PLAYING, Instant.now())));
addDay(220L, LocalDate.of(2026, 7, 1), 1440);
addDay(620L, LocalDate.of(2026, 7, 1), 1440);
addDay(620L, LocalDate.of(2026, 7, 2), 100);
assertThat(repository.longestDay(USER_ID)).contains(new DayMinutes(LocalDate.of(2026, 7, 1), 1440));
}
@Test @Test
void longestDay_SumsAllGamesOfTheDay() { void longestDay_SumsAllGamesOfTheDay() {
userGameRepository.saveAllAndFlush(List.of( userGameRepository.saveAllAndFlush(List.of(
@@ -535,6 +547,17 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.games(USER_ID, StatKey.PERFECT, List.of(220L, 620L), BERLIN, 0, 10)).hasSize(1); assertThat(repository.games(USER_ID, StatKey.PERFECT, List.of(220L, 620L), BERLIN, 0, 10)).hasSize(1);
} }
@Test
void games_LongestDay_CapsASetAt24Hours() {
seedSet();
LocalDate day = LocalDate.of(2026, 7, 1);
addDay(220L, day, 1440);
addDay(620L, day, 1440);
assertThat(repository.games(USER_ID, StatKey.LONGEST_DAY, day, BERLIN, 0, 10))
.extracting(UserStats.StatGame::minutes).containsExactly(1440L);
}
@Test @Test
void games_LongestDayListsASetOnceWithTheSumOfItsEditions() { void games_LongestDayListsASetOnceWithTheSumOfItsEditions() {
seedSet(); seedSet();
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more