dubs-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/.claude/settings.local.json +280 -0
  2. package/CLAUDE.md +46 -0
  3. package/CONNECT4_PRODUCTION_DEPLOY.md +155 -0
  4. package/CURRENT_SESSION.md +171 -0
  5. package/CURRENT_SESSION_DRAW.md +516 -0
  6. package/MARCH_MADNESS_SURVIVOR.md +254 -0
  7. package/PANDA.md +166 -0
  8. package/Procfile +4 -0
  9. package/README.md +476 -0
  10. package/controllers/livescoresController.js +376 -0
  11. package/controllers/pickemController.js +554 -0
  12. package/controllers/survivorAdminController.js +887 -0
  13. package/controllers/survivorController.js +623 -0
  14. package/cron/oracleMonitor.js +77 -0
  15. package/cron/pickemOracleMonitor.js +73 -0
  16. package/data/jackpot-history.json +952 -0
  17. package/data/ncaaTeams.js +406 -0
  18. package/documentation/API_SECURITY_GUIDE.md +327 -0
  19. package/documentation/ARCADE_API.md +593 -0
  20. package/documentation/ARCADE_IMPLEMENTATION_SUMMARY.md +399 -0
  21. package/documentation/ARCADE_QUICKSTART.md +242 -0
  22. package/documentation/AUTOMATIC_MODE_ORACLE.md +321 -0
  23. package/documentation/BUG_FIX_COHORT_DATE_DISPLAY.md +171 -0
  24. package/documentation/CLAIM_MIGRATION_INSTRUCTIONS.md +52 -0
  25. package/documentation/CLAIM_STATUS_FIX.md +67 -0
  26. package/documentation/CLI_TOOL_GUIDE.md +372 -0
  27. package/documentation/COHORT_RETENTION_ANALYSIS.md +295 -0
  28. package/documentation/COHORT_RETENTION_IMPLEMENTATION_COMPLETE.md +461 -0
  29. package/documentation/COHORT_RETENTION_SUMMARY.md +204 -0
  30. package/documentation/COMPLETE_PROJECT_SUMMARY.md +490 -0
  31. package/documentation/DATABASE_QUERIES.md +269 -0
  32. package/documentation/DATABASE_RETENTION_POLICY.md +390 -0
  33. package/documentation/DATABASE_SETUP_GUIDE.md +361 -0
  34. package/documentation/DATABASE_SETUP_SUMMARY.md +247 -0
  35. package/documentation/DEMO_API_CURL_COMMANDS.md +656 -0
  36. package/documentation/DEPLOYMENT_SUMMARY.txt +100 -0
  37. package/documentation/DUPLICATE_NOTIFICATIONS_FIXED.md +201 -0
  38. package/documentation/EXCHANGE_RATES_INTEGRATION.md +371 -0
  39. package/documentation/FINAL_API_PROTECTION_TABLE.md +175 -0
  40. package/documentation/GAME_START_NOTIFICATIONS_DEPLOYMENT.md +256 -0
  41. package/documentation/GAME_START_NOTIFICATIONS_INTEGRATION.md +275 -0
  42. package/documentation/HEROKU_DEPLOYMENT.md +134 -0
  43. package/documentation/HEROKU_SCHEDULER_SETUP.md +271 -0
  44. package/documentation/JACKPOT_API.md +521 -0
  45. package/documentation/JACKPOT_DEPLOYMENT_GUIDE.md +362 -0
  46. package/documentation/JWT_IMPLEMENTATION_SUMMARY.md +373 -0
  47. package/documentation/JWT_QUICK_SETUP.md +268 -0
  48. package/documentation/JWT_TESTING_GUIDE.md +404 -0
  49. package/documentation/KEEPER_RECOVERY_GUIDE.md +381 -0
  50. package/documentation/KEEPER_SETUP.md +206 -0
  51. package/documentation/KEEPER_STATE_MACHINE.md +423 -0
  52. package/documentation/LATEST_PRODUCTION_SETUP.md +387 -0
  53. package/documentation/LOCAL_VOTING_TEST.md +279 -0
  54. package/documentation/ORACLE_FIXES_SUMMARY.md +188 -0
  55. package/documentation/ORACLE_POSTGRESQL_UPDATE.md +202 -0
  56. package/documentation/PAYMENT_DEPLOYMENT.md +209 -0
  57. package/documentation/PNL_TRACKING_SETUP.md +189 -0
  58. package/documentation/PREVENTING_LOCKUP_ERRORS.md +472 -0
  59. package/documentation/PRODUCTION_READY_SUMMARY.md +227 -0
  60. package/documentation/PUBLIC_VS_PRIVATE_ENDPOINTS.md +278 -0
  61. package/documentation/QUICK_AUTH_SETUP.md +99 -0
  62. package/documentation/QUICK_DEPLOY.md +224 -0
  63. package/documentation/QUICK_FIX.md +114 -0
  64. package/documentation/QUICK_START.md +152 -0
  65. package/documentation/REFEREE_MODE_GUIDE.md +392 -0
  66. package/documentation/RETENTION_CORE_ACTION_UPDATE.md +313 -0
  67. package/documentation/RETENTION_UPDATE_SUMMARY.md +108 -0
  68. package/documentation/RUN_MIGRATION_NOW.md +39 -0
  69. package/documentation/SCRIPTS_UPDATE_SUMMARY.md +251 -0
  70. package/documentation/SETUP_GUIDE.md +184 -0
  71. package/documentation/STATE_MACHINE_IMPLEMENTATION.md +250 -0
  72. package/documentation/TELEGRAM_NOTIFICATIONS_DIAGNOSIS.md +361 -0
  73. package/documentation/UNIFIED_ARCHITECTURE.md +231 -0
  74. package/documentation/VOTING_DEPLOYMENT_SUMMARY.md +392 -0
  75. package/documentation/WEBSOCKET_ARCHITECTURE.md +881 -0
  76. package/documentation/WHAT_WE_BUILT_TODAY.md +369 -0
  77. package/documentation/latest/LATEST_PRODUCTION_SETUP.md +865 -0
  78. package/ecosystem.config.js +65 -0
  79. package/env.template +125 -0
  80. package/middleware/apiKeyAuth.js +136 -0
  81. package/middleware/authenticate.js +214 -0
  82. package/middleware/developerUserAuth.js +76 -0
  83. package/middleware/socketAuth.js +69 -0
  84. package/package.json +49 -0
  85. package/postman/Dubs-API-v1-With-Voting.postman_collection.json +555 -0
  86. package/postman/Dubs-API-v1.postman_collection.json +205 -0
  87. package/postman/Dubs_Developer_API.postman_collection.json +662 -0
  88. package/postman/QUICKSTART.md +118 -0
  89. package/postman/QUICK_REFERENCE.md +246 -0
  90. package/postman/README.md +71 -0
  91. package/postman/VOTING_API_GUIDE.md +426 -0
  92. package/refactor/Animations.md +148 -0
  93. package/refactor/Chat.md +252 -0
  94. package/routes/actionsRoutes.js +699 -0
  95. package/routes/adminRoutes.js +370 -0
  96. package/routes/analyticsRoutes.js +1262 -0
  97. package/routes/arcadeRoutes.js +557 -0
  98. package/routes/authRoutes.js +2310 -0
  99. package/routes/avatarRoutes.js +85 -0
  100. package/routes/botRoutes.js +211 -0
  101. package/routes/chatRoutes.js +377 -0
  102. package/routes/cryptoPriceRoutes.js +105 -0
  103. package/routes/developerRoutes.js +4201 -0
  104. package/routes/deviceRoutes.js +214 -0
  105. package/routes/dmRoutes.js +167 -0
  106. package/routes/esportsRoutes.js +806 -0
  107. package/routes/exchangeRateRoutes.js +233 -0
  108. package/routes/gamesRoutes.js +3028 -0
  109. package/routes/jackpotRoutes.js +754 -0
  110. package/routes/keeperMonitoringRoutes.js +156 -0
  111. package/routes/keeperWebhookRoutes.js +466 -0
  112. package/routes/livescoresRoutes.js +31 -0
  113. package/routes/pickemAdminRoutes.js +199 -0
  114. package/routes/pickemRoutes.js +231 -0
  115. package/routes/playerStatsRoutes.js +147 -0
  116. package/routes/portfolioRoutes.js +217 -0
  117. package/routes/promoRoutes.js +418 -0
  118. package/routes/referralEarningsRoutes.js +392 -0
  119. package/routes/socialRoutes.js +459 -0
  120. package/routes/sportsRoutes.js +1271 -0
  121. package/routes/survivorAdminRoutes.js +345 -0
  122. package/routes/survivorRoutes.js +756 -0
  123. package/routes/uploadRoutes.js +256 -0
  124. package/routes/userProfileRoutes.js +244 -0
  125. package/routes/whatsNewRoutes.js +331 -0
  126. package/scripts/.claude/settings.local.json +15 -0
  127. package/scripts/README.md +170 -0
  128. package/scripts/RESTART_EVERYTHING.sh +104 -0
  129. package/scripts/add-claim-columns.sql +48 -0
  130. package/scripts/add-crypto-prices-cache.sql +27 -0
  131. package/scripts/add-exchange-rates-cache.sql +40 -0
  132. package/scripts/add-game-invite-column.sql +23 -0
  133. package/scripts/add-game-invite-notification.sql +33 -0
  134. package/scripts/add-game-invite-telegram-pref.sql +16 -0
  135. package/scripts/add-game-joined-notification.sql +16 -0
  136. package/scripts/add-game-joined-pref.js +40 -0
  137. package/scripts/add-game-joined-preference.sql +6 -0
  138. package/scripts/add-game-start-notifications.sql +41 -0
  139. package/scripts/add-notification-flags-to-games.sql +55 -0
  140. package/scripts/add-pending-game-dismissals.sql +19 -0
  141. package/scripts/add-preferred-currency.sql +34 -0
  142. package/scripts/add-winner-columns.js +61 -0
  143. package/scripts/add_mention_system.sql +53 -0
  144. package/scripts/add_payment_system.sql +96 -0
  145. package/scripts/add_sports_event_id_column.sql +22 -0
  146. package/scripts/analyze-cohort-data-heroku.js +276 -0
  147. package/scripts/analyze-cohort-data.js +295 -0
  148. package/scripts/analyze-prod-cohorts.sh +10 -0
  149. package/scripts/backfill-matchup-images.js +245 -0
  150. package/scripts/backfill-missing-signatures.js +175 -0
  151. package/scripts/backfill-referral-earnings.js +202 -0
  152. package/scripts/check-chat-schema.js +130 -0
  153. package/scripts/check-db.sh +14 -0
  154. package/scripts/check_oracle_in_game.js +54 -0
  155. package/scripts/cleanup-database.js +193 -0
  156. package/scripts/clear-notification-cache.js +85 -0
  157. package/scripts/convert-mnemonic.js +50 -0
  158. package/scripts/create-users-table.sql +44 -0
  159. package/scripts/debug-cohort-counts.js +248 -0
  160. package/scripts/debug-winner-calc.js +84 -0
  161. package/scripts/deploy-payment-system.sh +118 -0
  162. package/scripts/deploy-to-heroku.sh +63 -0
  163. package/scripts/diagnose-locked-round.js +143 -0
  164. package/scripts/dubs-cli.js +720 -0
  165. package/scripts/dump-account.js +65 -0
  166. package/scripts/find-vrf-offset.js +48 -0
  167. package/scripts/fix-chat-notifications-constraint.sql +122 -0
  168. package/scripts/fix-claim-columns.js +124 -0
  169. package/scripts/fix-constraint-now.js +44 -0
  170. package/scripts/fix-lock-timestamps.js +96 -0
  171. package/scripts/fix-locked-round.sh +126 -0
  172. package/scripts/fix-missing-badges.sql +91 -0
  173. package/scripts/fix-payment-notifications.sql +41 -0
  174. package/scripts/force-new-round.js +55 -0
  175. package/scripts/force-resolve-and-claim.js +278 -0
  176. package/scripts/important/README.md +115 -0
  177. package/scripts/important/authority-force-lock.js +197 -0
  178. package/scripts/important/authority-resolve-game.js +267 -0
  179. package/scripts/important/check-game-status.js +373 -0
  180. package/scripts/important/list-pending-games-by-version.js +270 -0
  181. package/scripts/important/reconcile-v1-v2-payouts.js +270 -0
  182. package/scripts/initialize-jackpot.js +111 -0
  183. package/scripts/jackpot/.claude/settings.local.json +10 -0
  184. package/scripts/jackpot/force-reset.js +84 -0
  185. package/scripts/jackpot/initialize-mainnet.js +100 -0
  186. package/scripts/jackpot/keeper.js +742 -0
  187. package/scripts/jackpot/status.js +107 -0
  188. package/scripts/jackpot/update-round-duration.js +143 -0
  189. package/scripts/keeper-bot.js +112 -0
  190. package/scripts/list-pending-games.js +131 -0
  191. package/scripts/migrate-chat-v2.js +127 -0
  192. package/scripts/migrate-chat-winners.js +84 -0
  193. package/scripts/migrate-chat.sh +17 -0
  194. package/scripts/migrate-game-invite.js +83 -0
  195. package/scripts/migrate-heroku-game-notifications.sh +159 -0
  196. package/scripts/migrations/001_analytics_tables.sql +422 -0
  197. package/scripts/migrations/002_add_matchup_image_url.sql +14 -0
  198. package/scripts/migrations/003_referral_earnings.sql +208 -0
  199. package/scripts/migrations/004_add_whats_new_notification_type.sql +62 -0
  200. package/scripts/migrations/005_add_connect4_your_turn_notification.sql +61 -0
  201. package/scripts/migrations/005_push_notifications.sql +55 -0
  202. package/scripts/migrations/006_add_draw_team_players.sql +28 -0
  203. package/scripts/migrations/006_add_game_cancelled_notification.sql +62 -0
  204. package/scripts/migrations/007_add_gif_url.sql +8 -0
  205. package/scripts/migrations/008_add_connect4_columns.sql +139 -0
  206. package/scripts/migrations/008_add_pool_tracking.sql +22 -0
  207. package/scripts/migrations/009_create_survivor_pool_tables.sql +174 -0
  208. package/scripts/migrations/010_add_survivor_pool_outcome.sql +28 -0
  209. package/scripts/migrations/011_create_developer_tables.sql +67 -0
  210. package/scripts/migrations/011_fix_keeper_tables.sql +85 -0
  211. package/scripts/migrations/012_create_developer_webhooks.sql +31 -0
  212. package/scripts/migrations/013_add_network_mode.sql +18 -0
  213. package/scripts/migrations/014_create_developer_app_users.sql +19 -0
  214. package/scripts/migrations/015_add_ui_config.sql +4 -0
  215. package/scripts/migrations/016_add_resolution_secret.sql +4 -0
  216. package/scripts/migrations/017_add_external_game_id.sql +3 -0
  217. package/scripts/migrations/018_create_pickem_tables.sql +115 -0
  218. package/scripts/migrations/019_expo_push_tokens.sql +19 -0
  219. package/scripts/migrations/create_whats_new_tables.sql +88 -0
  220. package/scripts/migrations/drop_live_games_tables.sql +34 -0
  221. package/scripts/open-jackpot-round.js +85 -0
  222. package/scripts/purge-all-data.sh +329 -0
  223. package/scripts/purge-all-data.sql +142 -0
  224. package/scripts/purge-heroku-data.sh +149 -0
  225. package/scripts/purge-heroku-data.sql +62 -0
  226. package/scripts/rebuild-heroku-database.sh +113 -0
  227. package/scripts/recover-funds.js +357 -0
  228. package/scripts/regenerate-epl-images.js +278 -0
  229. package/scripts/resize-s3-matchup-images.js +374 -0
  230. package/scripts/resolve-direct.js +88 -0
  231. package/scripts/resolve-mock-game.js +124 -0
  232. package/scripts/resolve-pickem-game.js +55 -0
  233. package/scripts/resolve-round-manual.js +83 -0
  234. package/scripts/resolve-stuck-game.js +382 -0
  235. package/scripts/resolve-stuck-round.js +42 -0
  236. package/scripts/run-connect4-migration.sh +16 -0
  237. package/scripts/run-mention-migration.sh +32 -0
  238. package/scripts/run-payment-migration.sh +51 -0
  239. package/scripts/run-preferred-currency-migration.sh +31 -0
  240. package/scripts/run-referral-earnings-migration.sh +32 -0
  241. package/scripts/run-survivor-outcome-migration.sh +16 -0
  242. package/scripts/seed-test-users.js +346 -0
  243. package/scripts/setup-auth-tables.js +78 -0
  244. package/scripts/setup-complete-database.sql +992 -0
  245. package/scripts/setup-database-fresh.sh +359 -0
  246. package/scripts/setup-heroku-keeper.sh +48 -0
  247. package/scripts/setup-keeper-database.js +83 -0
  248. package/scripts/setup-keeper-state-db.sql +110 -0
  249. package/scripts/setup-oracle.sh +39 -0
  250. package/scripts/setup-pnl-tracking.js +111 -0
  251. package/scripts/start-devnet.sh +14 -0
  252. package/scripts/test-arcade-devnet.sh +160 -0
  253. package/scripts/test-arcade-match.sh +109 -0
  254. package/scripts/test-automatic-mode.sh +239 -0
  255. package/scripts/test-connect4-cancel-claim.js +370 -0
  256. package/scripts/test-connect4-e2e.js +369 -0
  257. package/scripts/test-connect4-resolve.js +369 -0
  258. package/scripts/test-game-state-endpoint.js +136 -0
  259. package/scripts/test-invite-notification.js +86 -0
  260. package/scripts/test-jackpot-api.sh +71 -0
  261. package/scripts/test-poll-confirmation.js +267 -0
  262. package/scripts/test-resolve-game.js +271 -0
  263. package/scripts/test-resolve-signature.js +223 -0
  264. package/scripts/test-signature-preservation.js +124 -0
  265. package/scripts/test-state-machine.js +291 -0
  266. package/scripts/test-webhook-receiver.js +60 -0
  267. package/scripts/update-notification-constraint.js +52 -0
  268. package/scripts/verify-account-layout.js +145 -0
  269. package/scripts/verify-winner-algorithm.js +278 -0
  270. package/server.js +5259 -0
  271. package/services/arcadeMatchService.js +763 -0
  272. package/services/automaticGameOracle.js +1596 -0
  273. package/services/chatService.js +1612 -0
  274. package/services/connect4GameService.js +1049 -0
  275. package/services/connect4NotificationService.js +374 -0
  276. package/services/cryptoPriceService.js +223 -0
  277. package/services/customGameResolver.js +260 -0
  278. package/services/db.js +79 -0
  279. package/services/directMessageService.js +389 -0
  280. package/services/discordNotifications.js +160 -0
  281. package/services/exchangeRateService.js +289 -0
  282. package/services/expoPushService.js +314 -0
  283. package/services/gamesCacheService.js +539 -0
  284. package/services/jackpotHistory.js +331 -0
  285. package/services/jackpotService.js +856 -0
  286. package/services/keeperStateService.js +355 -0
  287. package/services/matchupImageService.js +591 -0
  288. package/services/notificationCacheService.js +407 -0
  289. package/services/pickemOracle.js +440 -0
  290. package/services/playerStatsService.js +389 -0
  291. package/services/portfolioService.js +555 -0
  292. package/services/promoService.js +757 -0
  293. package/services/promoTreasuryService.js +239 -0
  294. package/services/pushNotifications.js +353 -0
  295. package/services/redisService.js +422 -0
  296. package/services/referralEarningsService.js +728 -0
  297. package/services/s3Service.js +396 -0
  298. package/services/socialService.js +1202 -0
  299. package/services/survivorOracle.js +469 -0
  300. package/services/survivorSimulator.js +475 -0
  301. package/services/telegramNotifications.js +461 -0
  302. package/services/userProfileStatsService.js +1185 -0
  303. package/services/whatsNewService.js +388 -0
  304. package/utils/urlHelper.js +95 -0
@@ -0,0 +1,539 @@
1
+ /**
2
+ * Games Cache Service - Redis-backed high-performance games list layer
3
+ *
4
+ * Architecture (per-user, like notificationCacheService):
5
+ * - Redis Sorted Set: user_games:{walletAddress} → game IDs sorted by joinedAt timestamp
6
+ * - Redis Hash: user_game:{walletAddress}:{gameId} → trimmed game data (list-view fields only)
7
+ * - No TTL: cache persists until explicitly invalidated. Kept in sync via write-through
8
+ * on create, join, resolve, and claim operations.
9
+ *
10
+ * Each user gets their own copy of game data (includes user-specific fields like
11
+ * claimedAt, teamChoice, etc.). This matches the notificationCacheService pattern
12
+ * and avoids cross-user concerns.
13
+ *
14
+ * Gracefully falls back to PostgreSQL when Redis is unavailable.
15
+ */
16
+
17
+ const redisService = require('./redisService');
18
+
19
+ // Key prefixes
20
+ const KEYS = {
21
+ USER_GAMES: (wallet) => `user_games:${wallet}`,
22
+ GAME: (wallet, gameId) => `user_game:${wallet}:${gameId}`,
23
+ };
24
+
25
+ // Maximum games to cache per user
26
+ const MAX_CACHED_GAMES = 50;
27
+
28
+ /**
29
+ * Fields stored in the game hash (trimmed from the full 30+ field response).
30
+ * Complex objects (arrays, objects) are JSON-stringified.
31
+ */
32
+ function serializeGameForCache(gameData) {
33
+ return {
34
+ gameId: gameData.gameId || '',
35
+ gameAddress: gameData.gameAddress || '',
36
+ title: gameData.title || '',
37
+ matchupImageUrl: gameData.matchupImageUrl || '',
38
+ imageUrl: gameData.imageUrl || '',
39
+ gameType: gameData.gameType || '',
40
+ buyIn: (gameData.buyIn ?? 0).toString(),
41
+ gameMode: (gameData.gameMode ?? 0).toString(),
42
+ createdBy: gameData.createdBy || '',
43
+ teamChoice: gameData.teamChoice || '',
44
+ claimedAt: gameData.claimedAt || '',
45
+ isLocked: gameData.isLocked ? '1' : '0',
46
+ isResolved: gameData.isResolved ? '1' : '0',
47
+ automaticStatus: gameData.automaticStatus || '',
48
+ status: gameData.status || '',
49
+ lockTimestamp: (gameData.lockTimestamp ?? '').toString(),
50
+ maxPlayers: (gameData.maxPlayers ?? 0).toString(),
51
+ // Arrays → JSON
52
+ homeTeamPlayers: JSON.stringify(gameData.homeTeamPlayers || []),
53
+ awayTeamPlayers: JSON.stringify(gameData.awayTeamPlayers || []),
54
+ drawTeamPlayers: JSON.stringify(gameData.drawTeamPlayers || []),
55
+ players: JSON.stringify(gameData.players || []),
56
+ // Extracted from sportsEvent (NOT the full blob)
57
+ strLeague: gameData.sportsEvent?.strLeague || gameData.strLeague || '',
58
+ strHomeTeam: gameData.sportsEvent?.strHomeTeam || gameData.homeTeam || '',
59
+ strAwayTeam: gameData.sportsEvent?.strAwayTeam || gameData.awayTeam || '',
60
+ strHomeTeamBadge: gameData.sportsEvent?.strHomeTeamBadge || gameData.homeTeamBadge || '',
61
+ strAwayTeamBadge: gameData.sportsEvent?.strAwayTeamBadge || gameData.awayTeamBadge || '',
62
+ strTimestamp: gameData.sportsEvent?.strTimestamp || gameData.strTimestamp || '',
63
+ // Final score fields
64
+ finalScoreWinner: gameData.finalScore?.winner ?? '',
65
+ finalScoreHome: (gameData.finalScore?.homeScore ?? '').toString(),
66
+ finalScoreAway: (gameData.finalScore?.awayScore ?? '').toString(),
67
+ // Connect4
68
+ winner: gameData.winner || '',
69
+ connect4Winner: gameData.connect4Winner || '',
70
+ // User-specific fields
71
+ role: gameData.role || '',
72
+ // CRITICAL: Convert Date objects to ISO strings before storing in Redis.
73
+ // Redis HSET calls .toString() on values, which produces "Thu Feb 12 2026 ..."
74
+ // format instead of ISO "2026-02-12T..." format. This caused inconsistent
75
+ // date formats between cache-served and DB-served responses.
76
+ joinedAt: gameData.joinedAt instanceof Date
77
+ ? gameData.joinedAt.toISOString()
78
+ : (gameData.joinedAt || ''),
79
+ mySignature: gameData.mySignature || '',
80
+ myExplorerUrl: gameData.myExplorerUrl || '',
81
+ walletType: gameData.walletType || '',
82
+ claimSignature: gameData.claimSignature || '',
83
+ claimExplorerUrl: gameData.claimExplorerUrl || '',
84
+ amountClaimed: (gameData.amountClaimed ?? '').toString(),
85
+ // Room name for billiards
86
+ roomName: gameData.roomName || '',
87
+ };
88
+ }
89
+
90
+ function deserializeGameFromCache(data) {
91
+ const finalScoreWinner = data.finalScoreWinner;
92
+ const finalScoreHome = data.finalScoreHome;
93
+ const finalScoreAway = data.finalScoreAway;
94
+
95
+ let finalScore = null;
96
+ if (finalScoreWinner !== '' || finalScoreHome !== '' || finalScoreAway !== '') {
97
+ finalScore = {
98
+ winner: finalScoreWinner === '' ? null : finalScoreWinner,
99
+ homeScore: finalScoreHome !== '' ? parseInt(finalScoreHome) || 0 : undefined,
100
+ awayScore: finalScoreAway !== '' ? parseInt(finalScoreAway) || 0 : undefined,
101
+ };
102
+ }
103
+
104
+ // Rebuild a minimal sportsEvent from extracted fields
105
+ const sportsEvent = {};
106
+ if (data.strLeague) sportsEvent.strLeague = data.strLeague;
107
+ if (data.strHomeTeam) sportsEvent.strHomeTeam = data.strHomeTeam;
108
+ if (data.strAwayTeam) sportsEvent.strAwayTeam = data.strAwayTeam;
109
+ if (data.strHomeTeamBadge) sportsEvent.strHomeTeamBadge = data.strHomeTeamBadge;
110
+ if (data.strAwayTeamBadge) sportsEvent.strAwayTeamBadge = data.strAwayTeamBadge;
111
+ if (data.strTimestamp) sportsEvent.strTimestamp = data.strTimestamp;
112
+ if (finalScore) sportsEvent.finalScore = finalScore;
113
+
114
+ return {
115
+ gameId: data.gameId,
116
+ gameAddress: data.gameAddress || null,
117
+ title: data.title || '',
118
+ imageUrl: data.imageUrl || null,
119
+ matchupImageUrl: data.matchupImageUrl || null,
120
+ gameType: data.gameType || '',
121
+ buyIn: parseFloat(data.buyIn) || 0,
122
+ maxPlayers: parseInt(data.maxPlayers) || 0,
123
+ gameMode: parseInt(data.gameMode) || 0,
124
+ createdBy: data.createdBy || null,
125
+ sportsEvent: Object.keys(sportsEvent).length > 0 ? sportsEvent : null,
126
+ homeTeam: data.strHomeTeam || undefined,
127
+ awayTeam: data.strAwayTeam || undefined,
128
+ league: data.strLeague || undefined,
129
+ homeTeamBadge: data.strHomeTeamBadge || undefined,
130
+ awayTeamBadge: data.strAwayTeamBadge || undefined,
131
+ strTimestamp: data.strTimestamp || undefined,
132
+ homeTeamPlayers: safeJsonParse(data.homeTeamPlayers) || [],
133
+ awayTeamPlayers: safeJsonParse(data.awayTeamPlayers) || [],
134
+ drawTeamPlayers: safeJsonParse(data.drawTeamPlayers) || [],
135
+ players: safeJsonParse(data.players) || [],
136
+ roomName: data.roomName || undefined,
137
+ lockTimestamp: data.lockTimestamp ? parseInt(data.lockTimestamp) || null : null,
138
+ isLocked: data.isLocked === '1',
139
+ isResolved: data.isResolved === '1',
140
+ automaticStatus: data.automaticStatus || null,
141
+ finalScore,
142
+ winner: data.winner || null,
143
+ connect4Winner: data.connect4Winner || null,
144
+ status: data.status || null,
145
+ // User-specific
146
+ role: data.role || null,
147
+ joinedAt: data.joinedAt || null,
148
+ teamChoice: data.teamChoice || null,
149
+ mySignature: data.mySignature || null,
150
+ myExplorerUrl: data.myExplorerUrl || null,
151
+ walletType: data.walletType || null,
152
+ claimedAt: data.claimedAt || null,
153
+ claimSignature: data.claimSignature || null,
154
+ claimExplorerUrl: data.claimExplorerUrl || null,
155
+ amountClaimed: data.amountClaimed ? parseFloat(data.amountClaimed) : null,
156
+ };
157
+ }
158
+
159
+ function safeJsonParse(str) {
160
+ if (!str) return null;
161
+ try {
162
+ return JSON.parse(str);
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ class GamesCacheService {
169
+ constructor() {
170
+ this.enabled = false;
171
+ }
172
+
173
+ /**
174
+ * Initialize the cache service
175
+ * Called during server startup (after redisService.connect())
176
+ */
177
+ async initialize() {
178
+ // Redis connection is already established by notificationCacheService
179
+ this.enabled = redisService.isAvailable();
180
+ if (this.enabled) {
181
+ console.log('[GamesCache] Redis caching enabled');
182
+ } else {
183
+ console.log('[GamesCache] Running without Redis cache (PostgreSQL fallback)');
184
+ }
185
+ return this.enabled;
186
+ }
187
+
188
+ isEnabled() {
189
+ return this.enabled && redisService.isAvailable();
190
+ }
191
+
192
+ // ============================================
193
+ // WRITE OPERATIONS
194
+ // ============================================
195
+
196
+ /**
197
+ * Cache a game for a specific user.
198
+ * Called after saving a game to PostgreSQL.
199
+ */
200
+ async cacheGame(walletAddress, gameData) {
201
+ if (!this.isEnabled()) return false;
202
+
203
+ try {
204
+ const gameId = gameData.gameId;
205
+ const timestamp = gameData.joinedAt
206
+ ? new Date(gameData.joinedAt).getTime()
207
+ : Date.now();
208
+
209
+ const pipeline = redisService.pipeline();
210
+ if (!pipeline) return false;
211
+
212
+ // 1. Add to user's sorted set
213
+ pipeline.zadd(KEYS.USER_GAMES(walletAddress), timestamp, gameId);
214
+
215
+ // 2. Store game data as hash
216
+ pipeline.hset(KEYS.GAME(walletAddress, gameId), serializeGameForCache(gameData));
217
+
218
+ // 3. Trim sorted set to max size
219
+ pipeline.zremrangebyrank(KEYS.USER_GAMES(walletAddress), 0, -(MAX_CACHED_GAMES + 1));
220
+
221
+ await pipeline.exec();
222
+
223
+ console.log(`[GamesCache] Cached game ${gameId} for ${walletAddress.slice(0, 8)}...`);
224
+ return true;
225
+ } catch (error) {
226
+ console.error('[GamesCache] Error caching game:', error.message);
227
+ return false;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Partial update of a game hash for a specific user.
233
+ * Only updates the fields provided.
234
+ */
235
+ async updateGame(walletAddress, gameId, fields) {
236
+ if (!this.isEnabled()) return false;
237
+
238
+ try {
239
+ const key = KEYS.GAME(walletAddress, gameId);
240
+
241
+ // Check if hash exists
242
+ const exists = await redisService.exists(key);
243
+ if (!exists) return false;
244
+
245
+ // Build partial update object
246
+ const update = {};
247
+ if (fields.isLocked !== undefined) update.isLocked = fields.isLocked ? '1' : '0';
248
+ if (fields.isResolved !== undefined) update.isResolved = fields.isResolved ? '1' : '0';
249
+ if (fields.automaticStatus !== undefined) update.automaticStatus = fields.automaticStatus;
250
+ if (fields.status !== undefined) update.status = fields.status;
251
+ if (fields.claimedAt !== undefined) update.claimedAt = fields.claimedAt || '';
252
+ if (fields.claimSignature !== undefined) update.claimSignature = fields.claimSignature || '';
253
+ if (fields.claimExplorerUrl !== undefined) update.claimExplorerUrl = fields.claimExplorerUrl || '';
254
+ if (fields.amountClaimed !== undefined) update.amountClaimed = (fields.amountClaimed ?? '').toString();
255
+ if (fields.finalScoreWinner !== undefined) update.finalScoreWinner = fields.finalScoreWinner ?? '';
256
+ if (fields.finalScoreHome !== undefined) update.finalScoreHome = (fields.finalScoreHome ?? '').toString();
257
+ if (fields.finalScoreAway !== undefined) update.finalScoreAway = (fields.finalScoreAway ?? '').toString();
258
+ if (fields.homeTeamPlayers !== undefined) update.homeTeamPlayers = JSON.stringify(fields.homeTeamPlayers);
259
+ if (fields.awayTeamPlayers !== undefined) update.awayTeamPlayers = JSON.stringify(fields.awayTeamPlayers);
260
+ if (fields.drawTeamPlayers !== undefined) update.drawTeamPlayers = JSON.stringify(fields.drawTeamPlayers);
261
+ if (fields.winner !== undefined) update.winner = fields.winner || '';
262
+ if (fields.connect4Winner !== undefined) update.connect4Winner = fields.connect4Winner || '';
263
+
264
+ if (Object.keys(update).length === 0) return false;
265
+
266
+ await redisService.hset(key, update);
267
+ return true;
268
+ } catch (error) {
269
+ console.error('[GamesCache] Error updating game:', error.message);
270
+ return false;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Update a game for ALL users who have it cached.
276
+ * Used for shared state changes (lock, resolve) that affect all participants.
277
+ */
278
+ async updateGameForAllUsers(gameId, playerWallets, fields) {
279
+ if (!this.isEnabled()) return false;
280
+
281
+ try {
282
+ for (const wallet of playerWallets) {
283
+ await this.updateGame(wallet, gameId, fields);
284
+ }
285
+ return true;
286
+ } catch (error) {
287
+ console.error('[GamesCache] Error updating game for all users:', error.message);
288
+ return false;
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Add a player to a game (when they join).
294
+ * Adds the game to the joiner's sorted set and creates their game hash.
295
+ * Also updates the team arrays in existing users' game hashes.
296
+ */
297
+ async addPlayerToGame(gameId, walletAddress, gameData, existingPlayerWallets) {
298
+ if (!this.isEnabled()) return false;
299
+
300
+ try {
301
+ // 1. Cache the game for the joining user
302
+ await this.cacheGame(walletAddress, gameData);
303
+
304
+ // 2. Update team arrays for all existing players
305
+ if (existingPlayerWallets && existingPlayerWallets.length > 0) {
306
+ const teamUpdate = {};
307
+ if (gameData.homeTeamPlayers) teamUpdate.homeTeamPlayers = gameData.homeTeamPlayers;
308
+ if (gameData.awayTeamPlayers) teamUpdate.awayTeamPlayers = gameData.awayTeamPlayers;
309
+ if (gameData.drawTeamPlayers) teamUpdate.drawTeamPlayers = gameData.drawTeamPlayers;
310
+
311
+ for (const existingWallet of existingPlayerWallets) {
312
+ if (existingWallet !== walletAddress) {
313
+ await this.updateGame(existingWallet, gameId, teamUpdate);
314
+ }
315
+ }
316
+ }
317
+
318
+ return true;
319
+ } catch (error) {
320
+ console.error('[GamesCache] Error adding player to game:', error.message);
321
+ return false;
322
+ }
323
+ }
324
+
325
+ // ============================================
326
+ // READ OPERATIONS
327
+ // ============================================
328
+
329
+ /**
330
+ * Get paginated games from cache.
331
+ * Uses cursor-based pagination with timestamps.
332
+ *
333
+ * @returns {Object|null} - { games, nextCursor, hasMore } or null on cache miss
334
+ */
335
+ async getGames(walletAddress, { limit = 10, cursor = null } = {}) {
336
+ if (!this.isEnabled()) return null;
337
+
338
+ // CRITICAL: Only serve the FIRST page from cache. Cursor (Load More) pages
339
+ // MUST always go to PostgreSQL. The cache is inherently incomplete — it holds
340
+ // at most MAX_CACHED_GAMES (50) items, warmed from the first page only.
341
+ // Serving cursor pages from an incomplete cache causes "missing games" where
342
+ // Load More returns a handful of games then stops, hiding hundreds of games.
343
+ if (cursor) {
344
+ console.log(`[GamesCache] Cursor page — bypassing cache for ${walletAddress.slice(0, 8)}...`);
345
+ return null;
346
+ }
347
+
348
+ try {
349
+ // Check if user has cached games
350
+ const exists = await redisService.exists(KEYS.USER_GAMES(walletAddress));
351
+ if (!exists) {
352
+ console.log(`[GamesCache] Cache miss for ${walletAddress.slice(0, 8)}...`);
353
+ return null;
354
+ }
355
+
356
+ // Check total cached count to know if cache is complete enough to serve first page
357
+ const totalCached = await redisService.zcard(KEYS.USER_GAMES(walletAddress));
358
+
359
+ // Get game IDs from sorted set (first page only)
360
+ let gameIds = await redisService.zrevrange(
361
+ KEYS.USER_GAMES(walletAddress),
362
+ 0,
363
+ limit // Returns limit+1 items (indices 0 through limit, inclusive)
364
+ );
365
+
366
+ if (!gameIds || gameIds.length === 0) {
367
+ return { games: [], nextCursor: null, hasMore: false };
368
+ }
369
+
370
+ // If we don't have more than limit items cached, cache may be incomplete
371
+ // (warmed from first page only). Fall through to PostgreSQL.
372
+ const hasMore = gameIds.length > limit;
373
+ if (!hasMore && totalCached <= limit) {
374
+ return null;
375
+ }
376
+ if (hasMore) {
377
+ gameIds = gameIds.slice(0, limit);
378
+ }
379
+
380
+ // Batch fetch game data
381
+ const gameKeys = gameIds.map(id => KEYS.GAME(walletAddress, id));
382
+ const gamesData = await redisService.hmgetall(gameKeys);
383
+
384
+ if (!gamesData) {
385
+ return null; // Cache miss on individual games
386
+ }
387
+
388
+ // Check for missing game hashes (orphaned sorted set entries).
389
+ // If ANY hashes are missing, the cache is inconsistent — fall through to DB.
390
+ const missingCount = gamesData.filter(data => data === null).length;
391
+ if (missingCount > 0) {
392
+ console.log(`[GamesCache] ${missingCount}/${gameIds.length} game hashes missing for ${walletAddress.slice(0, 8)}... — cache inconsistent, falling through to DB`);
393
+ // Clean up orphaned sorted set entries in the background
394
+ const orphanedIds = gameIds.filter((id, i) => gamesData[i] === null);
395
+ this._cleanupOrphanedEntries(walletAddress, orphanedIds).catch(() => {});
396
+ return null;
397
+ }
398
+
399
+ // Parse and format games
400
+ const games = gamesData
401
+ .map(data => deserializeGameFromCache(data));
402
+
403
+ // Get next cursor (timestamp of last item)
404
+ let nextCursor = null;
405
+ if (hasMore && games.length > 0) {
406
+ const lastGame = games[games.length - 1];
407
+ const lastGameId = lastGame.gameId;
408
+ // Get the score (timestamp) for the last game
409
+ const score = await redisService.zscore(KEYS.USER_GAMES(walletAddress), lastGameId);
410
+ if (score) {
411
+ nextCursor = score.toString();
412
+ }
413
+ }
414
+
415
+ return { games, nextCursor, hasMore };
416
+ } catch (error) {
417
+ console.error('[GamesCache] Error getting games:', error.message);
418
+ return null;
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Bulk populate cache from PostgreSQL (called on first cache miss).
424
+ */
425
+ async warmCache(walletAddress, games) {
426
+ if (!this.isEnabled() || games.length === 0) return false;
427
+
428
+ try {
429
+ const pipeline = redisService.pipeline();
430
+ if (!pipeline) return false;
431
+
432
+ for (const game of games) {
433
+ const gameId = game.gameId;
434
+ const timestamp = game.joinedAt
435
+ ? new Date(game.joinedAt).getTime()
436
+ : Date.now();
437
+
438
+ pipeline.zadd(KEYS.USER_GAMES(walletAddress), timestamp, gameId);
439
+ pipeline.hset(KEYS.GAME(walletAddress, gameId), serializeGameForCache(game));
440
+ }
441
+
442
+ // Trim to max size
443
+ pipeline.zremrangebyrank(KEYS.USER_GAMES(walletAddress), 0, -(MAX_CACHED_GAMES + 1));
444
+
445
+ await pipeline.exec();
446
+
447
+ console.log(`[GamesCache] Warmed cache with ${games.length} games for ${walletAddress.slice(0, 8)}...`);
448
+ return true;
449
+ } catch (error) {
450
+ console.error('[GamesCache] Error warming cache:', error.message);
451
+ return false;
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Invalidate user's game cache.
457
+ */
458
+ async invalidateUserCache(walletAddress) {
459
+ if (!this.isEnabled()) return false;
460
+
461
+ try {
462
+ const gameIds = await redisService.zrevrange(KEYS.USER_GAMES(walletAddress), 0, -1);
463
+
464
+ if (gameIds && gameIds.length > 0) {
465
+ const keysToDelete = gameIds.map(id => KEYS.GAME(walletAddress, id));
466
+ keysToDelete.push(KEYS.USER_GAMES(walletAddress));
467
+ await redisService.del(...keysToDelete);
468
+ }
469
+
470
+ console.log(`[GamesCache] Invalidated cache for ${walletAddress.slice(0, 8)}...`);
471
+ return true;
472
+ } catch (error) {
473
+ console.error('[GamesCache] Error invalidating cache:', error.message);
474
+ return false;
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Flush ALL games cache data from Redis.
480
+ * Safe to call in production — all requests will fall through to PostgreSQL
481
+ * and re-warm the cache with correct data on next access.
482
+ * @returns {{ deletedKeys: number }} - Number of keys deleted
483
+ */
484
+ async flushAllGamesCaches() {
485
+ if (!this.isEnabled()) return { deletedKeys: 0 };
486
+
487
+ try {
488
+ // Use SCAN to find all games cache keys (non-blocking, safe for production)
489
+ const sortedSetKeys = await redisService.scanKeys('user_games:*');
490
+ const hashKeys = await redisService.scanKeys('user_game:*');
491
+ const allKeys = [...sortedSetKeys, ...hashKeys];
492
+
493
+ if (allKeys.length === 0) {
494
+ console.log('[GamesCache] No games cache keys found to flush');
495
+ return { deletedKeys: 0 };
496
+ }
497
+
498
+ // Delete in batches of 500 to avoid overloading Redis
499
+ const batchSize = 500;
500
+ for (let i = 0; i < allKeys.length; i += batchSize) {
501
+ const batch = allKeys.slice(i, i + batchSize);
502
+ await redisService.del(...batch);
503
+ }
504
+
505
+ console.log(`[GamesCache] Flushed ${allKeys.length} keys (${sortedSetKeys.length} sorted sets, ${hashKeys.length} game hashes)`);
506
+ return { deletedKeys: allKeys.length };
507
+ } catch (error) {
508
+ console.error('[GamesCache] Error flushing all caches:', error.message);
509
+ throw error;
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Remove orphaned game IDs from the sorted set (IDs whose hash data is missing).
515
+ * Called in the background when inconsistency is detected during reads.
516
+ */
517
+ async _cleanupOrphanedEntries(walletAddress, orphanedGameIds) {
518
+ if (!this.isEnabled() || orphanedGameIds.length === 0) return;
519
+
520
+ try {
521
+ const pipeline = redisService.pipeline();
522
+ if (!pipeline) return;
523
+
524
+ for (const gameId of orphanedGameIds) {
525
+ pipeline.zrem(KEYS.USER_GAMES(walletAddress), gameId);
526
+ }
527
+ await pipeline.exec();
528
+
529
+ console.log(`[GamesCache] Cleaned up ${orphanedGameIds.length} orphaned entries for ${walletAddress.slice(0, 8)}...`);
530
+ } catch (error) {
531
+ console.error('[GamesCache] Error cleaning orphaned entries:', error.message);
532
+ }
533
+ }
534
+ }
535
+
536
+ // Singleton instance
537
+ const gamesCacheService = new GamesCacheService();
538
+
539
+ module.exports = gamesCacheService;