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,3028 @@
1
+ /**
2
+ * Games Routes - Handle sports betting games
3
+ */
4
+
5
+ const express = require('express');
6
+ const router = express.Router();
7
+ const { pool } = require('../services/db'); // Shared database pool
8
+ const notificationCacheService = require('../services/notificationCacheService');
9
+ const referralEarningsService = require('../services/referralEarningsService');
10
+ const discordNotifications = require('../services/discordNotifications');
11
+ const connect4GameService = require('../services/connect4GameService');
12
+ const gamesCacheService = require('../services/gamesCacheService');
13
+
14
+ // Matchup image generation (lazy loaded to avoid startup failures if canvas not installed)
15
+ let matchupImageService = null;
16
+ let s3Service = null;
17
+
18
+ function getMatchupImageService() {
19
+ if (!matchupImageService) {
20
+ try {
21
+ matchupImageService = require('../services/matchupImageService');
22
+ console.log('🎨 Matchup image service loaded');
23
+ } catch (err) {
24
+ console.warn('âš ī¸ Matchup image service not available:', err.message);
25
+ console.warn(' Install canvas: npm install canvas');
26
+ }
27
+ }
28
+ return matchupImageService;
29
+ }
30
+
31
+ function getS3Service() {
32
+ if (!s3Service) {
33
+ try {
34
+ const S3Service = require('../services/s3Service');
35
+ s3Service = new S3Service();
36
+ } catch (err) {
37
+ console.warn('âš ī¸ S3 service not available:', err.message);
38
+ }
39
+ }
40
+ return s3Service;
41
+ }
42
+
43
+ /**
44
+ * Calculate correct pool amounts for games (handles legacy + pari-mutuel hybrid)
45
+ * For legacy players (not in player_amounts), uses buy_in
46
+ * For pari-mutuel players, uses their actual bet amount from player_amounts
47
+ */
48
+ function calculatePoolAmounts(game) {
49
+ const buyIn = parseFloat(game.buy_in) || 0;
50
+ const playerAmounts = game.player_amounts || {};
51
+ const homePlayers = game.home_team_players || [];
52
+ const awayPlayers = game.away_team_players || [];
53
+ const drawPlayers = game.draw_team_players || [];
54
+
55
+ // Calculate pool for each team by summing player amounts
56
+ // Legacy players (not in playerAmounts) use the fixed buy_in
57
+ const calcTeamPool = (players) => {
58
+ return players.reduce((sum, wallet) => {
59
+ const amount = playerAmounts[wallet] !== undefined
60
+ ? parseFloat(playerAmounts[wallet])
61
+ : buyIn;
62
+ return sum + amount;
63
+ }, 0);
64
+ };
65
+
66
+ const homePool = calcTeamPool(homePlayers);
67
+ const awayPool = calcTeamPool(awayPlayers);
68
+ const drawPool = calcTeamPool(drawPlayers);
69
+ const totalPool = homePool + awayPool + drawPool;
70
+
71
+ return {
72
+ homePool: homePool || undefined,
73
+ awayPool: awayPool || undefined,
74
+ drawPool: drawPool || undefined,
75
+ totalPool: totalPool || undefined,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Get short nickname for EPL teams
81
+ * Maps full team names to commonly used short names
82
+ */
83
+ const EPL_NICKNAMES = {
84
+ 'manchester city': 'Man City',
85
+ 'manchester united': 'Man Utd',
86
+ 'wolverhampton wanderers': 'Wolves',
87
+ 'tottenham hotspur': 'Spurs',
88
+ 'nottingham forest': "Nott'm Forest",
89
+ 'brighton and hove albion': 'Brighton',
90
+ 'brighton & hove albion': 'Brighton',
91
+ 'west ham united': 'West Ham',
92
+ 'newcastle united': 'Newcastle',
93
+ 'leeds united': 'Leeds',
94
+ 'afc bournemouth': 'Bournemouth',
95
+ 'crystal palace': 'Crystal Palace',
96
+ 'aston villa': 'Aston Villa',
97
+ 'leicester city': 'Leicester',
98
+ 'sheffield united': 'Sheffield Utd',
99
+ };
100
+
101
+ function getTeamNickname(teamName) {
102
+ if (!teamName) return teamName;
103
+ const lowerName = teamName.toLowerCase().trim();
104
+ return EPL_NICKNAMES[lowerName] || teamName;
105
+ }
106
+
107
+ // Socket.IO will be injected
108
+ let io = null;
109
+ let chatNamespace = null;
110
+
111
+ // Inject Socket.IO instance
112
+ router.setSocketIO = (ioInstance, chatNS) => {
113
+ io = ioInstance;
114
+ chatNamespace = chatNS;
115
+ console.log('🔌 Socket.IO injected into games routes');
116
+ };
117
+
118
+ /**
119
+ * Enrich gameInvite with S3 matchup URL from games table
120
+ * Replaces data URLs with S3 URLs for better performance
121
+ * @param {Pool} pool - Database connection pool
122
+ * @param {Object} gameInvite - Game invite object (may have data URL)
123
+ * @returns {Promise<Object>} - Enriched gameInvite with S3 URL
124
+ */
125
+ async function enrichGameInviteWithS3Url(pool, gameInvite) {
126
+ if (!gameInvite?.gameId) {
127
+ return gameInvite;
128
+ }
129
+
130
+ try {
131
+ const gameUrlResult = await pool.query(
132
+ 'SELECT matchup_image_url FROM games WHERE game_id = $1',
133
+ [gameInvite.gameId]
134
+ );
135
+
136
+ if (gameUrlResult.rows.length > 0 && gameUrlResult.rows[0].matchup_image_url) {
137
+ return {
138
+ ...gameInvite,
139
+ matchupImageUrl: gameUrlResult.rows[0].matchup_image_url,
140
+ };
141
+ }
142
+ } catch (error) {
143
+ console.error('[gamesRoutes] Error enriching gameInvite with S3 URL:', error.message);
144
+ }
145
+
146
+ return gameInvite; // Return original if enrichment fails
147
+ }
148
+
149
+ /**
150
+ * Generate matchup image and upload to S3
151
+ * Updates the game record with the matchup_image_url
152
+ *
153
+ * @param {string} gameId - Game ID
154
+ * @param {Object} sportsEvent - Sports event data with team names and league
155
+ */
156
+ async function generateAndUploadMatchupImage(gameId, sportsEvent) {
157
+ const league = sportsEvent.strLeague?.toUpperCase() || 'NHL';
158
+
159
+ // Extract just the league abbreviation from full name
160
+ // e.g., "National Hockey League" -> "NHL"
161
+ const leagueMap = {
162
+ 'NATIONAL HOCKEY LEAGUE': 'NHL',
163
+ 'NATIONAL BASKETBALL ASSOCIATION': 'NBA',
164
+ 'NATIONAL FOOTBALL LEAGUE': 'NFL',
165
+ 'MAJOR LEAGUE BASEBALL': 'MLB',
166
+ 'ENGLISH PREMIER LEAGUE': 'EPL',
167
+ 'ULTIMATE FIGHTING CHAMPIONSHIP': 'UFC',
168
+ 'FIGHTING': 'UFC',
169
+ 'NCAA DIVISION I BASKETBALL MENS': 'NCAAB',
170
+ 'NCAA DIVISION 1 COLLEGE FOOTBALL': 'NCAAF',
171
+ 'NHL': 'NHL',
172
+ 'NBA': 'NBA',
173
+ 'NFL': 'NFL',
174
+ 'MLB': 'MLB',
175
+ 'EPL': 'EPL',
176
+ 'UFC': 'UFC',
177
+ 'NCAAB': 'NCAAB',
178
+ 'NCAAF': 'NCAAF'
179
+ };
180
+ const normalizedLeague = leagueMap[league.toUpperCase()] || league.toUpperCase();
181
+
182
+ // UFC now uses local fighter images just like other sports
183
+ // No special handling needed - falls through to standard matchup generation below
184
+
185
+ // For non-UFC leagues, we need the matchup image services
186
+ const matchupService = getMatchupImageService();
187
+ const s3 = getS3Service();
188
+
189
+ if (!matchupService || !s3 || !s3.isConfigured()) {
190
+ console.log(`[MatchupImage] Skipping for ${gameId} - services not available`);
191
+ return null;
192
+ }
193
+
194
+ const homeTeam = sportsEvent.strHomeTeam;
195
+ const awayTeam = sportsEvent.strAwayTeam;
196
+
197
+ if (!homeTeam || !awayTeam) {
198
+ console.log(`[MatchupImage] Skipping for ${gameId} - missing team names`);
199
+ return null;
200
+ }
201
+
202
+ // EPL and UFC use "Home vs Away" convention, US sports use "Away @ Home"
203
+ const isHomeFirst = normalizedLeague === 'EPL' || normalizedLeague === 'UFC';
204
+ const leftTeam = isHomeFirst ? homeTeam : awayTeam;
205
+ const rightTeam = isHomeFirst ? awayTeam : homeTeam;
206
+
207
+ console.log(`[MatchupImage] Generating for ${gameId}: ${leftTeam} vs ${rightTeam} (${normalizedLeague}) [HomeFirst=${isHomeFirst}]`);
208
+
209
+ try {
210
+ // Key uses left_right order to match the visual layout
211
+ const matchupKey = s3.getMatchupImageKey(leftTeam, rightTeam, normalizedLeague);
212
+
213
+ // For EPL, always regenerate (don't reuse old images that may have wrong team order)
214
+ // For other leagues, check if image already exists to allow reuse
215
+ const isEPL = normalizedLeague === 'EPL';
216
+ const exists = isEPL ? false : await s3.matchupImageExists(matchupKey);
217
+
218
+ let publicUrl;
219
+ if (exists) {
220
+ // Image already exists, reuse it (non-EPL only)
221
+ // Add cache-busting timestamp for consistency
222
+ const cacheBuster = Date.now();
223
+ publicUrl = `https://${s3.bucketName}.s3.${s3.region}.amazonaws.com/${matchupKey}?v=${cacheBuster}`;
224
+ console.log(`[MatchupImage] â™ģī¸ Reusing existing image for ${leftTeam} vs ${rightTeam}`);
225
+ } else {
226
+ // Generate the image at full quality (600x315), will be resized to 300x158 on upload
227
+ const result = await matchupService.generateMatchupImage({
228
+ homeTeam,
229
+ awayTeam,
230
+ league: normalizedLeague,
231
+ width: 600,
232
+ height: 315
233
+ });
234
+
235
+ // Upload to S3 (force overwrite for EPL to replace bad images)
236
+ // Key uses left_right order to match the visual layout
237
+ const uploadResult = await s3.uploadMatchupImage(leftTeam, rightTeam, normalizedLeague, result.buffer, isEPL);
238
+ publicUrl = uploadResult.publicUrl;
239
+
240
+ if (uploadResult.wasReused && !isEPL) {
241
+ console.log(`[MatchupImage] â™ģī¸ Image was created by another process, reusing it`);
242
+ }
243
+ }
244
+
245
+ // Update the game record with the URL
246
+ await pool.query(`
247
+ UPDATE games
248
+ SET matchup_image_url = $1, updated_at = NOW()
249
+ WHERE game_id = $2
250
+ `, [publicUrl, gameId]);
251
+
252
+ console.log(`[MatchupImage] ✅ Saved for ${gameId}: ${publicUrl}`);
253
+
254
+ return publicUrl;
255
+ } catch (err) {
256
+ console.error(`[MatchupImage] ❌ Failed for ${gameId}:`, err.message);
257
+ throw err;
258
+ }
259
+ }
260
+
261
+ // Database connection - using shared pool from services/db.js
262
+
263
+ // Initialize games tables
264
+ async function initializeTables() {
265
+ try {
266
+ // Create games table
267
+ await pool.query(`
268
+ CREATE TABLE IF NOT EXISTS games (
269
+ id SERIAL PRIMARY KEY,
270
+ game_id VARCHAR(255) UNIQUE NOT NULL,
271
+ game_address VARCHAR(255) NOT NULL,
272
+ title VARCHAR(500),
273
+ image_url TEXT,
274
+ game_type VARCHAR(50),
275
+ buy_in DECIMAL(20, 9),
276
+ max_players INTEGER DEFAULT 0,
277
+ game_mode INTEGER,
278
+ created_by VARCHAR(255) NOT NULL,
279
+ sports_event JSONB,
280
+ home_team_players TEXT[] DEFAULT '{}',
281
+ away_team_players TEXT[] DEFAULT '{}',
282
+ lock_timestamp BIGINT,
283
+ is_locked BOOLEAN DEFAULT false,
284
+ is_resolved BOOLEAN DEFAULT false,
285
+ automatic_status VARCHAR(50),
286
+ lock_notification_sent_10min BOOLEAN DEFAULT false,
287
+ lock_notification_sent_now BOOLEAN DEFAULT false,
288
+ created_at TIMESTAMP DEFAULT NOW(),
289
+ updated_at TIMESTAMP DEFAULT NOW()
290
+ )
291
+ `);
292
+
293
+ // Create user_game_refs table (user-specific game data)
294
+ await pool.query(`
295
+ CREATE TABLE IF NOT EXISTS user_game_refs (
296
+ id SERIAL PRIMARY KEY,
297
+ wallet_address VARCHAR(255) NOT NULL,
298
+ game_id VARCHAR(255) NOT NULL,
299
+ role VARCHAR(50),
300
+ joined_at TIMESTAMP,
301
+ team_choice VARCHAR(10),
302
+ my_signature VARCHAR(255),
303
+ my_explorer_url TEXT,
304
+ status VARCHAR(50),
305
+ wallet_type VARCHAR(50),
306
+ claimed_at TIMESTAMP,
307
+ claim_signature TEXT,
308
+ claim_explorer_url TEXT,
309
+ amount_claimed DECIMAL(20, 9),
310
+ created_at TIMESTAMP DEFAULT NOW(),
311
+ updated_at TIMESTAMP DEFAULT NOW(),
312
+ UNIQUE(wallet_address, game_id)
313
+ )
314
+ `);
315
+
316
+ // Create audit_logs table
317
+ await pool.query(`
318
+ CREATE TABLE IF NOT EXISTS audit_logs (
319
+ id SERIAL PRIMARY KEY,
320
+ log_type VARCHAR(100),
321
+ method VARCHAR(100),
322
+ user_id VARCHAR(255),
323
+ metadata JSONB,
324
+ created_at TIMESTAMP DEFAULT NOW()
325
+ )
326
+ `);
327
+
328
+ console.log('✅ Games tables initialized');
329
+ } catch (error) {
330
+ console.error('Error initializing games tables:', error);
331
+ }
332
+ }
333
+
334
+ // Initialize tables on startup
335
+ initializeTables();
336
+
337
+ /**
338
+ * POST /api/auth/games/save
339
+ * Save a new game and user's game reference
340
+ */
341
+ router.post('/save', async (req, res) => {
342
+ try {
343
+ const { walletAddress, gameId, sharedGameData, userGameRef } = req.body;
344
+
345
+ if (!walletAddress || !gameId || !userGameRef) {
346
+ return res.status(400).json({
347
+ success: false,
348
+ error: 'Wallet address, game ID, and user game reference are required'
349
+ });
350
+ }
351
+
352
+ // Verify transaction on-chain for Connect4 games before saving
353
+ // This prevents ghost records from failed transactions (e.g., insufficient funds)
354
+ if (gameId.startsWith('c4-') && userGameRef.mySignature) {
355
+ console.log(`[saveGame] 🔍 Verifying Connect4 transaction: ${userGameRef.mySignature.slice(0, 20)}...`);
356
+ const verification = await connect4GameService.verifyTransactionSuccess(userGameRef.mySignature);
357
+
358
+ if (!verification.success) {
359
+ console.log(`[saveGame] ❌ Transaction verification failed for ${gameId}: ${verification.error}`);
360
+ return res.status(400).json({
361
+ success: false,
362
+ error: verification.error || 'Transaction failed on-chain',
363
+ code: 'TRANSACTION_FAILED',
364
+ details: {
365
+ signature: userGameRef.mySignature,
366
+ gameId,
367
+ }
368
+ });
369
+ }
370
+ console.log(`[saveGame] ✅ Transaction verified for ${gameId}`);
371
+ }
372
+
373
+ // 1. Save shared game data (if provided - only creator sends this)
374
+ if (sharedGameData) {
375
+ const {
376
+ title: originalTitle,
377
+ imageUrl,
378
+ matchupImageUrl,
379
+ gameType,
380
+ gameAddress,
381
+ buyIn,
382
+ maxPlayers,
383
+ gameMode,
384
+ createdBy,
385
+ sportsEvent,
386
+ homeTeamPlayers,
387
+ awayTeamPlayers,
388
+ drawTeamPlayers,
389
+ lockTimestamp,
390
+ invitedPlayer,
391
+ } = sharedGameData;
392
+
393
+ // For sports games, reconstruct title based on league convention
394
+ // US Sports (NHL, NBA, NFL, MLB): "Away @ Home" format
395
+ // Soccer (EPL): "Home vs Away" format with short nicknames
396
+ let title = originalTitle;
397
+ if (gameMode === 4 && sportsEvent?.strHomeTeam && sportsEvent?.strAwayTeam) {
398
+ const league = sportsEvent.strLeague?.toUpperCase() || '';
399
+ const isEPL = league.includes('PREMIER') || league === 'EPL';
400
+
401
+ if (isEPL) {
402
+ // EPL: "Home vs Away" with short nicknames
403
+ const homeNick = getTeamNickname(sportsEvent.strHomeTeam);
404
+ const awayNick = getTeamNickname(sportsEvent.strAwayTeam);
405
+ title = `${homeNick} vs ${awayNick}`;
406
+ } else {
407
+ // US Sports: "Away @ Home" format
408
+ title = `${sportsEvent.strAwayTeam} @ ${sportsEvent.strHomeTeam}`;
409
+ }
410
+ console.log(`[saveGame] Reconstructed title: "${originalTitle}" -> "${title}" (EPL: ${isEPL})`);
411
+ }
412
+
413
+ // Calculate lock_timestamp from sportsEvent.strTimestamp if not provided
414
+ let finalLockTimestamp = lockTimestamp;
415
+ if (!finalLockTimestamp && gameMode === 4 && sportsEvent?.strTimestamp) {
416
+ // Convert ISO timestamp to Unix timestamp (seconds since epoch)
417
+ const lockDate = new Date(sportsEvent.strTimestamp + 'Z');
418
+ finalLockTimestamp = Math.floor(lockDate.getTime() / 1000);
419
+ console.log(`[saveGame] Calculated lock_timestamp from strTimestamp: ${sportsEvent.strTimestamp} -> ${finalLockTimestamp}`);
420
+ }
421
+
422
+ // Calculate initial pool amounts based on creator's team choice
423
+ const creatorTeamChoice = userGameRef?.teamChoice;
424
+ const initialHomePool = creatorTeamChoice === 'home' ? buyIn : 0;
425
+ const initialAwayPool = creatorTeamChoice === 'away' ? buyIn : 0;
426
+ const initialDrawPool = creatorTeamChoice === 'draw' ? buyIn : 0;
427
+ const initialTotalPool = buyIn || 0;
428
+ const initialPlayerAmounts = createdBy ? { [createdBy]: buyIn } : {};
429
+
430
+ await pool.query(`
431
+ INSERT INTO games (
432
+ game_id, game_address, title, image_url, game_type, buy_in,
433
+ max_players, game_mode, created_by, sports_event,
434
+ home_team_players, away_team_players, draw_team_players, lock_timestamp,
435
+ is_locked, is_resolved, automatic_status, matchup_image_url, connect4_current_turn, invited_player,
436
+ home_pool, away_pool, draw_pool, total_pool, player_amounts
437
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, false, false, 'pending', $15, $16, $17, $18, $19, $20, $21, $22)
438
+ ON CONFLICT (game_id)
439
+ DO UPDATE SET
440
+ title = EXCLUDED.title,
441
+ game_type = COALESCE(EXCLUDED.game_type, games.game_type),
442
+ home_team_players = EXCLUDED.home_team_players,
443
+ away_team_players = EXCLUDED.away_team_players,
444
+ draw_team_players = EXCLUDED.draw_team_players,
445
+ lock_timestamp = COALESCE(EXCLUDED.lock_timestamp, games.lock_timestamp),
446
+ matchup_image_url = COALESCE(EXCLUDED.matchup_image_url, games.matchup_image_url),
447
+ connect4_current_turn = COALESCE(EXCLUDED.connect4_current_turn, games.connect4_current_turn),
448
+ invited_player = COALESCE(EXCLUDED.invited_player, games.invited_player),
449
+ home_pool = COALESCE(EXCLUDED.home_pool, games.home_pool),
450
+ away_pool = COALESCE(EXCLUDED.away_pool, games.away_pool),
451
+ draw_pool = COALESCE(EXCLUDED.draw_pool, games.draw_pool),
452
+ total_pool = COALESCE(EXCLUDED.total_pool, games.total_pool),
453
+ player_amounts = COALESCE(EXCLUDED.player_amounts, games.player_amounts),
454
+ updated_at = NOW()
455
+ `, [
456
+ gameId,
457
+ gameAddress,
458
+ title,
459
+ imageUrl,
460
+ gameType,
461
+ buyIn,
462
+ maxPlayers || 0,
463
+ gameMode,
464
+ createdBy,
465
+ JSON.stringify(sportsEvent),
466
+ homeTeamPlayers || [],
467
+ awayTeamPlayers || [],
468
+ drawTeamPlayers || [],
469
+ finalLockTimestamp,
470
+ matchupImageUrl || null,
471
+ gameType === 'connect4' ? 'home' : null,
472
+ invitedPlayer || null,
473
+ initialHomePool,
474
+ initialAwayPool,
475
+ initialDrawPool,
476
+ initialTotalPool,
477
+ JSON.stringify(initialPlayerAmounts),
478
+ ]);
479
+
480
+ console.log(`[saveGame] Saved shared game data for ${gameId} (lock_timestamp: ${finalLockTimestamp})`);
481
+ }
482
+
483
+ // 2. Save user-specific reference
484
+ const {
485
+ role,
486
+ joinedAt,
487
+ teamChoice,
488
+ mySignature,
489
+ myExplorerUrl,
490
+ status,
491
+ walletType,
492
+ } = userGameRef;
493
+
494
+ await pool.query(`
495
+ INSERT INTO user_game_refs (
496
+ wallet_address, game_id, role, joined_at, team_choice,
497
+ my_signature, my_explorer_url, status, wallet_type
498
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
499
+ ON CONFLICT (wallet_address, game_id)
500
+ DO UPDATE SET
501
+ team_choice = EXCLUDED.team_choice,
502
+ my_signature = COALESCE(EXCLUDED.my_signature, user_game_refs.my_signature),
503
+ my_explorer_url = COALESCE(EXCLUDED.my_explorer_url, user_game_refs.my_explorer_url),
504
+ status = EXCLUDED.status
505
+ `, [
506
+ walletAddress,
507
+ gameId,
508
+ role,
509
+ joinedAt || new Date().toISOString(),
510
+ teamChoice,
511
+ mySignature,
512
+ myExplorerUrl,
513
+ status,
514
+ walletType,
515
+ ]);
516
+
517
+ console.log(`[saveGame] Saved game reference ${gameId} for user ${walletAddress} (role: ${role})`);
518
+
519
+ // 2.5. Connect 4 join - start the game and notify creator when opponent joins
520
+ if (gameId.startsWith('c4-') && role === 'player' && teamChoice === 'away') {
521
+ try {
522
+ console.log(`[saveGame] 🔴 Connect 4 join detected - adding player to away_team and starting game...`);
523
+
524
+ // CRITICAL: Add the joiner to away_team_players first
525
+ await pool.query(`
526
+ UPDATE games
527
+ SET away_team_players = array_append(away_team_players, $1),
528
+ updated_at = NOW()
529
+ WHERE game_id = $2
530
+ `, [walletAddress, gameId]);
531
+ console.log(`[saveGame] 🟡 Added ${walletAddress} to away_team_players`);
532
+
533
+ // Start the Connect 4 game (sets status to 'playing', preserves board)
534
+ const startedGame = await connect4GameService.startGame(gameId);
535
+ console.log(`[saveGame] 🔴🟡 Connect 4 game ${gameId} started! Status: ${startedGame?.status}`);
536
+
537
+ // Look up the game to get creator info
538
+ const gameResult = await pool.query(
539
+ `SELECT g.game_id, g.created_by, g.buy_in, u.id as creator_user_id, u.username as creator_username
540
+ FROM games g
541
+ LEFT JOIN users u ON g.created_by = u.wallet_address
542
+ WHERE g.game_id = $1`,
543
+ [gameId]
544
+ );
545
+
546
+ if (gameResult.rows.length > 0) {
547
+ const game = gameResult.rows[0];
548
+ const creatorWallet = game.created_by;
549
+ const creatorUserId = game.creator_user_id;
550
+ const creatorUsername = game.creator_username;
551
+
552
+ // Look up joiner info
553
+ const joinerResult = await pool.query(
554
+ `SELECT id, username, avatar FROM users WHERE wallet_address = $1`,
555
+ [walletAddress]
556
+ );
557
+ const joiner = joinerResult.rows[0] || {};
558
+ const joinerUserId = joiner.id;
559
+ const joinerUsername = joiner.username || `${walletAddress.slice(0, 4)}...${walletAddress.slice(-4)}`;
560
+ const joinerAvatar = joiner.avatar || `https://api.dicebear.com/9.x/adventurer/svg?seed=${walletAddress}`;
561
+
562
+ // Emit WebSocket event to creator so their UI updates
563
+ if (chatNamespace && creatorUserId) {
564
+ const playerJoinedEvent = {
565
+ type: 'connect4_player_joined',
566
+ gameId,
567
+ player: {
568
+ walletAddress,
569
+ username: joinerUsername,
570
+ avatar: joinerAvatar,
571
+ teamChoice: 'away',
572
+ color: 'yellow',
573
+ },
574
+ };
575
+ chatNamespace.to(`user-${creatorUserId}`).emit('connect4_update', playerJoinedEvent);
576
+ console.log(`[saveGame] 🔴 Emitted connect4_player_joined to user-${creatorUserId} (${creatorUsername})`);
577
+
578
+ // Also emit the full game state so the creator's overlay updates
579
+ if (startedGame) {
580
+ chatNamespace.to(`user-${creatorUserId}`).emit('connect4_game_state', startedGame);
581
+ console.log(`[saveGame] 🔴 Emitted connect4_game_state to creator`);
582
+ }
583
+ }
584
+
585
+ // Also emit to the joiner's room so they get the updated game state
586
+ if (chatNamespace && joinerUserId && startedGame) {
587
+ chatNamespace.to(`user-${joinerUserId}`).emit('connect4_game_state', startedGame);
588
+ console.log(`[saveGame] 🟡 Emitted connect4_game_state to joiner user-${joinerUserId}`);
589
+ }
590
+
591
+ // Broadcast to the connect4 game room as well
592
+ if (chatNamespace && startedGame) {
593
+ chatNamespace.to(`connect4:${gameId}`).emit('connect4_game_state', startedGame);
594
+ console.log(`[saveGame] 🔴🟡 Emitted connect4_game_state to room connect4:${gameId}`);
595
+ }
596
+
597
+ // Send Telegram notification to creator
598
+ if (creatorUserId) {
599
+ const { forwardChatNotification } = require('../services/telegramNotifications');
600
+ const message = `${joinerUsername} joined your Connect 4 game! 🔴🟡\n\nBuy-in: ${game.buy_in} SOL\nThey're playing as Yellow.\n\nIt's your turn (Red)!`;
601
+ await forwardChatNotification(pool, creatorUserId, 'game_joined', joinerUsername, message, { gameId });
602
+ console.log(`[saveGame] 📱 Telegram notification sent to creator ${creatorUsername}`);
603
+ }
604
+ }
605
+ } catch (notifError) {
606
+ console.warn(`[saveGame] âš ī¸ Connect 4 join notification failed:`, notifError.message);
607
+ }
608
+ }
609
+
610
+ // 3. Generate matchup image (BLOCKING for sports games so we can return the URL)
611
+ // Only for sports games (gameMode 4) with valid sportsEvent data
612
+ // For Connect4 and other games, use the matchupImageUrl from sharedGameData if provided
613
+ let matchupImageUrl = sharedGameData?.matchupImageUrl || null;
614
+ if (sharedGameData && sharedGameData.gameMode === 4 && sharedGameData.sportsEvent) {
615
+ try {
616
+ matchupImageUrl = await generateAndUploadMatchupImage(gameId, sharedGameData.sportsEvent);
617
+ console.log(`[saveGame] Matchup image generated for ${gameId}: ${matchupImageUrl}`);
618
+ } catch (err) {
619
+ console.warn(`[saveGame] Matchup image generation failed for ${gameId}:`, err.message);
620
+ }
621
+ }
622
+
623
+ // 4. Post to Discord (non-blocking) - only for new games (when sharedGameData is provided)
624
+ if (sharedGameData) {
625
+ // Look up creator's username
626
+ pool.query('SELECT username FROM users WHERE wallet_address = $1', [sharedGameData.createdBy])
627
+ .then(result => {
628
+ const creatorUsername = result.rows[0]?.username || null;
629
+ return discordNotifications.notifyNewGame({
630
+ gameId,
631
+ title: sharedGameData.title,
632
+ buyIn: sharedGameData.buyIn,
633
+ sportsEvent: sharedGameData.sportsEvent,
634
+ creatorUsername,
635
+ matchupImageUrl,
636
+ gameType: sharedGameData.gameType
637
+ });
638
+ })
639
+ .catch(err => console.warn(`[saveGame] Discord notification failed for ${gameId}:`, err.message));
640
+ }
641
+
642
+ // Cache game to Redis (non-blocking)
643
+ // When sharedGameData is null (joiner, not creator), fetch from DB to avoid caching empty fields
644
+ (async () => {
645
+ try {
646
+ let cacheSource = sharedGameData;
647
+ if (!cacheSource) {
648
+ const gameRow = await pool.query('SELECT * FROM games WHERE game_id = $1', [gameId]);
649
+ if (gameRow.rows[0]) {
650
+ const g = gameRow.rows[0];
651
+ cacheSource = {
652
+ gameAddress: g.game_address,
653
+ title: g.title,
654
+ imageUrl: g.image_url,
655
+ matchupImageUrl: g.matchup_image_url,
656
+ gameType: g.game_type,
657
+ buyIn: parseFloat(g.buy_in) || 0,
658
+ maxPlayers: g.max_players,
659
+ gameMode: g.game_mode,
660
+ createdBy: g.created_by,
661
+ sportsEvent: g.sports_event,
662
+ homeTeamPlayers: g.home_team_players || [],
663
+ awayTeamPlayers: g.away_team_players || [],
664
+ drawTeamPlayers: g.draw_team_players || [],
665
+ lockTimestamp: g.lock_timestamp,
666
+ };
667
+ }
668
+ }
669
+ await gamesCacheService.cacheGame(walletAddress, {
670
+ gameId,
671
+ gameAddress: cacheSource?.gameAddress || null,
672
+ title: cacheSource?.title || '',
673
+ imageUrl: cacheSource?.imageUrl || null,
674
+ matchupImageUrl: matchupImageUrl || cacheSource?.matchupImageUrl || null,
675
+ gameType: cacheSource?.gameType || '',
676
+ buyIn: cacheSource?.buyIn || 0,
677
+ maxPlayers: cacheSource?.maxPlayers || 0,
678
+ gameMode: cacheSource?.gameMode || 0,
679
+ createdBy: cacheSource?.createdBy || walletAddress,
680
+ sportsEvent: cacheSource?.sportsEvent || null,
681
+ homeTeamPlayers: cacheSource?.homeTeamPlayers || [],
682
+ awayTeamPlayers: cacheSource?.awayTeamPlayers || [],
683
+ drawTeamPlayers: cacheSource?.drawTeamPlayers || [],
684
+ lockTimestamp: cacheSource?.lockTimestamp || null,
685
+ isLocked: false,
686
+ isResolved: false,
687
+ automaticStatus: 'pending',
688
+ status: status || 'active',
689
+ role: role || 'creator',
690
+ joinedAt: joinedAt || new Date().toISOString(),
691
+ teamChoice: teamChoice || null,
692
+ mySignature: mySignature || null,
693
+ myExplorerUrl: myExplorerUrl || null,
694
+ walletType: walletType || null,
695
+ });
696
+ } catch (err) {
697
+ console.warn('[saveGame] Redis cache write failed:', err.message);
698
+ }
699
+ })();
700
+
701
+ res.status(200).json({
702
+ success: true,
703
+ message: 'Game saved successfully',
704
+ matchupImageUrl: matchupImageUrl
705
+ });
706
+
707
+ } catch (error) {
708
+ console.error('[saveGame] Error:', error);
709
+ res.status(500).json({
710
+ success: false,
711
+ error: error.message || 'Failed to save game'
712
+ });
713
+ }
714
+ });
715
+
716
+ /**
717
+ * POST /api/auth/games/:gameId/join
718
+ * Join an existing game
719
+ */
720
+ router.post('/:gameId/join', async (req, res) => {
721
+ try {
722
+ const { gameId } = req.params;
723
+ const { walletAddress, userGameRef, teamChoice, amount } = req.body;
724
+
725
+ if (!walletAddress || !userGameRef) {
726
+ return res.status(400).json({
727
+ success: false,
728
+ error: 'Wallet address and user game reference are required'
729
+ });
730
+ }
731
+
732
+ // Parse amount (in SOL) - default to 0 if not provided (legacy support)
733
+ const betAmount = amount ? parseFloat(amount) : 0;
734
+
735
+ // 1. Update game with new player and pool amounts
736
+ if (teamChoice) {
737
+ // For automatic/sports games, add to appropriate team
738
+ const teamField = teamChoice === 'home' ? 'home_team_players'
739
+ : teamChoice === 'away' ? 'away_team_players'
740
+ : 'draw_team_players';
741
+
742
+ // Determine which pool to update
743
+ const poolField = teamChoice === 'home' ? 'home_pool'
744
+ : teamChoice === 'away' ? 'away_pool'
745
+ : 'draw_pool';
746
+
747
+ await pool.query(`
748
+ UPDATE games
749
+ SET ${teamField} = array_append(${teamField}, $1),
750
+ ${poolField} = COALESCE(${poolField}, 0) + $3,
751
+ total_pool = COALESCE(total_pool, 0) + $3,
752
+ player_amounts = COALESCE(player_amounts, '{}'::jsonb) || jsonb_build_object($1, $3),
753
+ updated_at = NOW()
754
+ WHERE game_id = $2
755
+ `, [walletAddress, gameId, betAmount]);
756
+
757
+ console.log(`[joinGame] Added ${walletAddress} to ${teamChoice} team in game ${gameId} with ${betAmount} SOL`);
758
+ }
759
+
760
+ // 2. Save user's game reference
761
+ const {
762
+ role,
763
+ joinedAt,
764
+ mySignature,
765
+ myExplorerUrl,
766
+ status,
767
+ walletType,
768
+ } = userGameRef;
769
+
770
+ await pool.query(`
771
+ INSERT INTO user_game_refs (
772
+ wallet_address, game_id, role, joined_at, team_choice,
773
+ my_signature, my_explorer_url, status, wallet_type
774
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
775
+ ON CONFLICT (wallet_address, game_id)
776
+ DO UPDATE SET
777
+ team_choice = EXCLUDED.team_choice,
778
+ my_signature = COALESCE(EXCLUDED.my_signature, user_game_refs.my_signature),
779
+ my_explorer_url = COALESCE(EXCLUDED.my_explorer_url, user_game_refs.my_explorer_url),
780
+ status = EXCLUDED.status
781
+ `, [
782
+ walletAddress,
783
+ gameId,
784
+ role || 'player',
785
+ joinedAt || new Date().toISOString(),
786
+ teamChoice,
787
+ mySignature,
788
+ myExplorerUrl,
789
+ status || 'active',
790
+ walletType,
791
+ ]);
792
+
793
+ console.log(`[joinGame] Saved game reference ${gameId} for user ${walletAddress}`);
794
+
795
+ // Get updated game data to broadcast
796
+ const gameResult = await pool.query('SELECT * FROM games WHERE game_id = $1', [gameId]);
797
+ const updatedGame = gameResult.rows[0];
798
+
799
+ // Broadcast player joined event to all connected clients
800
+ if (chatNamespace && updatedGame) {
801
+ chatNamespace.emit('game:player_joined', {
802
+ gameId: gameId,
803
+ walletAddress: walletAddress,
804
+ teamChoice: teamChoice,
805
+ homeTeamCount: updatedGame.home_team_players?.length || 0,
806
+ awayTeamCount: updatedGame.away_team_players?.length || 0,
807
+ drawTeamCount: updatedGame.draw_team_players?.length || 0,
808
+ totalPlayers: (updatedGame.home_team_players?.length || 0) + (updatedGame.away_team_players?.length || 0) + (updatedGame.draw_team_players?.length || 0),
809
+ timestamp: Date.now()
810
+ });
811
+ console.log(`[joinGame] 📡 Broadcasted game:player_joined event`);
812
+ }
813
+
814
+ // Cache: add the joining player's game entry + update existing players' team arrays
815
+ if (updatedGame) {
816
+ const allPlayers = [
817
+ ...(updatedGame.home_team_players || []),
818
+ ...(updatedGame.away_team_players || []),
819
+ ...(updatedGame.draw_team_players || []),
820
+ ];
821
+ const sportsEvent = updatedGame.sports_event;
822
+ gamesCacheService.addPlayerToGame(gameId, walletAddress, {
823
+ gameId,
824
+ gameAddress: updatedGame.game_address,
825
+ title: updatedGame.title,
826
+ imageUrl: updatedGame.image_url,
827
+ matchupImageUrl: updatedGame.matchup_image_url,
828
+ gameType: updatedGame.game_type,
829
+ buyIn: parseFloat(updatedGame.buy_in) || 0,
830
+ maxPlayers: updatedGame.max_players,
831
+ gameMode: updatedGame.game_mode,
832
+ createdBy: updatedGame.created_by,
833
+ sportsEvent,
834
+ homeTeamPlayers: updatedGame.home_team_players || [],
835
+ awayTeamPlayers: updatedGame.away_team_players || [],
836
+ drawTeamPlayers: updatedGame.draw_team_players || [],
837
+ lockTimestamp: updatedGame.lock_timestamp,
838
+ isLocked: updatedGame.is_locked,
839
+ isResolved: updatedGame.is_resolved,
840
+ automaticStatus: updatedGame.automatic_status,
841
+ status: status || 'active',
842
+ role: role || 'player',
843
+ joinedAt: joinedAt || new Date().toISOString(),
844
+ teamChoice,
845
+ mySignature,
846
+ myExplorerUrl,
847
+ walletType,
848
+ }, allPlayers).catch(err => console.warn('[joinGame] Redis cache write failed:', err.message));
849
+ }
850
+
851
+ res.status(200).json({
852
+ success: true,
853
+ message: 'Successfully joined game'
854
+ });
855
+
856
+ } catch (error) {
857
+ console.error('[joinGame] Error:', error);
858
+ res.status(500).json({
859
+ success: false,
860
+ error: error.message || 'Failed to join game'
861
+ });
862
+ }
863
+ });
864
+
865
+ /**
866
+ * GET /api/games/health
867
+ * Check server health (database connectivity, oracle status)
868
+ * NOTE: Must be BEFORE /:gameId route
869
+ */
870
+ router.get('/health', async (req, res) => {
871
+ try {
872
+ const startTime = Date.now();
873
+
874
+ // Test database connectivity
875
+ await pool.query('SELECT 1');
876
+
877
+ const responseTime = Date.now() - startTime;
878
+
879
+ // Check oracle health - look for recent resolutions
880
+ const oracleCheck = await pool.query(`
881
+ SELECT
882
+ COUNT(*) as resolved_count,
883
+ MAX(updated_at) as last_resolution
884
+ FROM games
885
+ WHERE is_resolved = true
886
+ AND game_mode IN (4, 5)
887
+ AND updated_at > NOW() - INTERVAL '24 hours'
888
+ `);
889
+
890
+ const resolvedLast24h = parseInt(oracleCheck.rows[0].resolved_count);
891
+ const lastResolution = oracleCheck.rows[0].last_resolution;
892
+
893
+ // Oracle is healthy if it resolved games in last 24h OR there are no pending games to resolve
894
+ const pendingGamesCheck = await pool.query(`
895
+ SELECT COUNT(*) as count FROM games
896
+ WHERE game_mode IN (4, 5)
897
+ AND is_resolved = false
898
+ AND is_locked = true
899
+ `);
900
+ const pendingGames = parseInt(pendingGamesCheck.rows[0].count);
901
+
902
+ // Oracle considered healthy if:
903
+ // - It resolved games recently (last 24h), OR
904
+ // - There are no pending games to resolve
905
+ const oracleHealthy = resolvedLast24h > 0 || pendingGames === 0;
906
+
907
+ res.status(200).json({
908
+ success: true,
909
+ health: {
910
+ status: 'healthy',
911
+ database: 'connected',
912
+ responseTime: `${responseTime}ms`,
913
+ oracle: {
914
+ status: oracleHealthy ? 'healthy' : 'delayed',
915
+ resolvedLast24h: resolvedLast24h,
916
+ lastResolution: lastResolution,
917
+ pendingGames: pendingGames
918
+ },
919
+ timestamp: new Date().toISOString()
920
+ }
921
+ });
922
+
923
+ } catch (error) {
924
+ console.error('[health] Error:', error);
925
+ res.status(503).json({
926
+ success: false,
927
+ health: {
928
+ status: 'unhealthy',
929
+ database: 'disconnected',
930
+ oracle: {
931
+ status: 'unknown'
932
+ },
933
+ error: error.message,
934
+ timestamp: new Date().toISOString()
935
+ }
936
+ });
937
+ }
938
+ });
939
+
940
+ /**
941
+ * GET /api/games/stats
942
+ * Get platform statistics (total games, total players, etc.)
943
+ * NOTE: Must be BEFORE /:gameId route to avoid "stats" being treated as a game ID
944
+ */
945
+ router.get('/stats', async (req, res) => {
946
+ try {
947
+ // Count total games created
948
+ const totalGamesResult = await pool.query('SELECT COUNT(*) as count FROM games');
949
+ const totalGames = parseInt(totalGamesResult.rows[0].count);
950
+
951
+ // Count total unique players (from user_game_refs)
952
+ const totalPlayersResult = await pool.query('SELECT COUNT(DISTINCT wallet_address) as count FROM user_game_refs');
953
+ const totalPlayers = parseInt(totalPlayersResult.rows[0].count);
954
+
955
+ // Count resolved games
956
+ const resolvedGamesResult = await pool.query('SELECT COUNT(*) as count FROM games WHERE is_resolved = true');
957
+ const resolvedGames = parseInt(resolvedGamesResult.rows[0].count);
958
+
959
+ console.log('[getStats] Total games:', totalGames, 'Total players:', totalPlayers, 'Resolved:', resolvedGames);
960
+
961
+ res.status(200).json({
962
+ success: true,
963
+ stats: {
964
+ totalGames,
965
+ totalPlayers,
966
+ resolvedGames,
967
+ activeGames: totalGames - resolvedGames
968
+ }
969
+ });
970
+
971
+ } catch (error) {
972
+ console.error('[getStats] Error:', error);
973
+ res.status(500).json({
974
+ success: false,
975
+ error: error.message
976
+ });
977
+ }
978
+ });
979
+
980
+ /**
981
+ * GET /api/games/sports-event/:sportsEventId/existing
982
+ * Get existing bets for a specific sports event
983
+ *
984
+ * Returns:
985
+ * - Your own existing bet on this event (if any)
986
+ * - Friend bets on this event that you can join
987
+ *
988
+ * This helps users avoid creating duplicate bets and discover friend bets to join.
989
+ *
990
+ * Requires authentication (JWT token)
991
+ *
992
+ * NOTE: This route MUST be defined before /:gameId to avoid being caught by it
993
+ */
994
+ router.get('/sports-event/:sportsEventId/existing', async (req, res) => {
995
+ const { sportsEventId } = req.params;
996
+
997
+ // Get wallet address from Authorization header (JWT token)
998
+ const authHeader = req.headers.authorization;
999
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
1000
+ return res.status(401).json({
1001
+ success: false,
1002
+ error: 'Authentication required'
1003
+ });
1004
+ }
1005
+
1006
+ try {
1007
+ // Decode JWT to get user info
1008
+ const token = authHeader.split(' ')[1];
1009
+ const jwt = require('jsonwebtoken');
1010
+ const decoded = jwt.verify(token, process.env.JWT_SECRET);
1011
+ const userId = decoded.userId;
1012
+ const walletAddress = decoded.walletAddress;
1013
+
1014
+ if (!userId || !walletAddress) {
1015
+ return res.status(401).json({
1016
+ success: false,
1017
+ error: 'Invalid token'
1018
+ });
1019
+ }
1020
+
1021
+ console.log(`[existingBets] Looking for existing bets on event ${sportsEventId} for user ${userId} (${walletAddress})`);
1022
+
1023
+ // Debug: Check friend counts and friend bets
1024
+ const friendCountQuery = await pool.query(`
1025
+ SELECT
1026
+ (SELECT COUNT(*) FROM user_relationships WHERE (user_id = $1 OR target_user_id = $1) AND relationship_type = 'friend') as user_rel_count,
1027
+ (SELECT COUNT(*) FROM friends WHERE (user_wallet = $2 OR friend_wallet = $2) AND status = 'accepted') as friends_count
1028
+ `, [userId, walletAddress]);
1029
+ console.log(`[existingBets] Friend counts - user_relationships: ${friendCountQuery.rows[0].user_rel_count}, friends table: ${friendCountQuery.rows[0].friends_count}`);
1030
+
1031
+ // Debug: Check all games for this sports event (regardless of creator)
1032
+ // Match by idEvent (sports) OR pandascoreMatchId (esports)
1033
+ const allGamesForEvent = await pool.query(`
1034
+ SELECT
1035
+ g.game_id,
1036
+ g.created_by,
1037
+ g.is_locked,
1038
+ g.is_resolved,
1039
+ g.home_team_players,
1040
+ g.away_team_players,
1041
+ g.draw_team_players,
1042
+ creator.username as creator_username,
1043
+ creator.id as creator_id
1044
+ FROM games g
1045
+ JOIN users creator ON g.created_by = creator.wallet_address
1046
+ WHERE (g.sports_event->>'idEvent' = $1 OR g.sports_event->>'pandascoreMatchId' = $1)
1047
+ `, [sportsEventId]);
1048
+ console.log(`[existingBets] All games for this event: ${allGamesForEvent.rows.length}`);
1049
+ allGamesForEvent.rows.forEach(g => {
1050
+ const isInHome = g.home_team_players?.includes(walletAddress);
1051
+ const isInAway = g.away_team_players?.includes(walletAddress);
1052
+ const isInDraw = g.draw_team_players?.includes(walletAddress);
1053
+ console.log(` - Game ${g.game_id} by @${g.creator_username} (id:${g.creator_id}), locked:${g.is_locked}, resolved:${g.is_resolved}, userInHome:${isInHome}, userInAway:${isInAway}, userInDraw:${isInDraw}`);
1054
+ });
1055
+
1056
+ // Debug: Check if creator is friend of current user
1057
+ if (allGamesForEvent.rows.length > 0) {
1058
+ for (const game of allGamesForEvent.rows) {
1059
+ const friendCheck = await pool.query(`
1060
+ SELECT
1061
+ EXISTS(SELECT 1 FROM user_relationships WHERE user_id = $1 AND target_user_id = $2 AND relationship_type = 'friend') as is_friend_dir1,
1062
+ EXISTS(SELECT 1 FROM user_relationships WHERE user_id = $2 AND target_user_id = $1 AND relationship_type = 'friend') as is_friend_dir2
1063
+ `, [userId, game.creator_id]);
1064
+ console.log(` - Is @${game.creator_username} (id:${game.creator_id}) friend of user ${userId}? dir1:${friendCheck.rows[0].is_friend_dir1}, dir2:${friendCheck.rows[0].is_friend_dir2}`);
1065
+ }
1066
+ }
1067
+
1068
+ // Query 1: Find ALL bets the user is participating in for this event
1069
+ const myBetsResult = await pool.query(`
1070
+ SELECT
1071
+ g.game_id,
1072
+ g.game_address,
1073
+ g.title,
1074
+ g.image_url,
1075
+ g.matchup_image_url,
1076
+ g.buy_in,
1077
+ g.sports_event,
1078
+ g.home_team_players,
1079
+ g.away_team_players,
1080
+ g.draw_team_players,
1081
+ g.lock_timestamp,
1082
+ g.is_locked,
1083
+ g.is_resolved,
1084
+ g.created_by,
1085
+ ugr.team_choice as my_team_choice,
1086
+ creator.username as creator_username,
1087
+ creator.avatar as creator_avatar,
1088
+ creator.wallet_address as creator_wallet
1089
+ FROM games g
1090
+ JOIN user_game_refs ugr ON g.game_id = ugr.game_id
1091
+ JOIN users creator ON g.created_by = creator.wallet_address
1092
+ WHERE
1093
+ (g.sports_event->>'idEvent' = $1 OR g.sports_event->>'pandascoreMatchId' = $1)
1094
+ AND ugr.wallet_address = $2
1095
+ AND g.is_resolved = false
1096
+ ORDER BY g.created_at DESC
1097
+ LIMIT 10
1098
+ `, [sportsEventId, walletAddress]);
1099
+
1100
+ // Process my bets - separate into "mine" (created) and "joined" (participating but not created)
1101
+ const myBets = myBetsResult.rows.map(row => ({
1102
+ gameId: row.game_id,
1103
+ gameAddress: row.game_address,
1104
+ title: row.title,
1105
+ imageUrl: row.image_url,
1106
+ matchupImageUrl: row.matchup_image_url,
1107
+ buyIn: parseFloat(row.buy_in),
1108
+ sportsEvent: row.sports_event,
1109
+ homeTeam: row.sports_event?.strHomeTeam,
1110
+ awayTeam: row.sports_event?.strAwayTeam,
1111
+ homeTeamBadge: row.sports_event?.strHomeTeamBadge,
1112
+ awayTeamBadge: row.sports_event?.strAwayTeamBadge,
1113
+ league: row.sports_event?.strLeague,
1114
+ strTimestamp: row.sports_event?.strTimestamp,
1115
+ homeTeamPlayers: row.home_team_players || [],
1116
+ awayTeamPlayers: row.away_team_players || [],
1117
+ drawTeamPlayers: row.draw_team_players || [],
1118
+ totalPlayers: (row.home_team_players?.length || 0) + (row.away_team_players?.length || 0) + (row.draw_team_players?.length || 0),
1119
+ lockTimestamp: row.lock_timestamp,
1120
+ isLocked: row.is_locked,
1121
+ isResolved: row.is_resolved,
1122
+ isCreator: row.created_by === walletAddress,
1123
+ myTeamChoice: row.my_team_choice,
1124
+ creator: {
1125
+ walletAddress: row.creator_wallet,
1126
+ username: row.creator_username,
1127
+ avatar: row.creator_avatar,
1128
+ },
1129
+ }));
1130
+
1131
+ // For backwards compatibility, return the first "own" bet as myBet
1132
+ const myBet = myBets.find(b => b.isCreator) || myBets[0] || null;
1133
+ console.log(`[existingBets] User has ${myBets.length} bet(s) on this event`);
1134
+
1135
+ // Query 2: Find FRIEND bets on this event (including ones user already joined)
1136
+ // Check friendships in BOTH tables and BOTH directions:
1137
+ // - user_relationships table (uses user IDs)
1138
+ // - friends table (uses wallet addresses)
1139
+ //
1140
+ // UPDATED: Now checks if ANY PARTICIPANT is a friend, not just the creator
1141
+ const friendBetsResult = await pool.query(`
1142
+ SELECT
1143
+ g.game_id,
1144
+ g.game_address,
1145
+ g.title,
1146
+ g.image_url,
1147
+ g.matchup_image_url,
1148
+ g.buy_in,
1149
+ g.sports_event,
1150
+ g.home_team_players,
1151
+ g.away_team_players,
1152
+ g.draw_team_players,
1153
+ g.lock_timestamp,
1154
+ g.is_locked,
1155
+ g.is_resolved,
1156
+ g.created_by,
1157
+ creator.username as creator_username,
1158
+ creator.avatar as creator_avatar,
1159
+ creator.wallet_address as creator_wallet,
1160
+ -- Check if current user has already joined this game
1161
+ ($3 = ANY(g.home_team_players) OR $3 = ANY(g.away_team_players) OR $3 = ANY(g.draw_team_players)) as user_already_joined
1162
+ FROM games g
1163
+ JOIN users creator ON g.created_by = creator.wallet_address
1164
+ WHERE
1165
+ -- Match by sports event ID (idEvent for sports, pandascoreMatchId for esports)
1166
+ (g.sports_event->>'idEvent' = $1 OR g.sports_event->>'pandascoreMatchId' = $1)
1167
+ -- Only active games
1168
+ AND g.is_resolved = false
1169
+ AND g.is_locked = false
1170
+ -- Game has a friend participating (creator OR any participant)
1171
+ AND (
1172
+ -- OPTION 1: Creator is a friend (original logic)
1173
+ -- Check user_relationships table (user IDs) - both directions
1174
+ creator.id IN (
1175
+ SELECT target_user_id
1176
+ FROM user_relationships
1177
+ WHERE user_id = $2 AND relationship_type = 'friend'
1178
+ )
1179
+ OR
1180
+ creator.id IN (
1181
+ SELECT user_id
1182
+ FROM user_relationships
1183
+ WHERE target_user_id = $2 AND relationship_type = 'friend'
1184
+ )
1185
+ -- Check friends table (wallet addresses) - both directions
1186
+ OR
1187
+ creator.wallet_address IN (
1188
+ SELECT friend_wallet
1189
+ FROM friends
1190
+ WHERE user_wallet = $3 AND status = 'accepted'
1191
+ )
1192
+ OR
1193
+ creator.wallet_address IN (
1194
+ SELECT user_wallet
1195
+ FROM friends
1196
+ WHERE friend_wallet = $3 AND status = 'accepted'
1197
+ )
1198
+
1199
+ -- OPTION 2: Any participant is a friend (NEW LOGIC)
1200
+ -- Check if any home team player is a friend (via friends table)
1201
+ OR
1202
+ EXISTS (
1203
+ SELECT 1
1204
+ FROM unnest(g.home_team_players) AS player_wallet
1205
+ WHERE player_wallet IN (
1206
+ SELECT friend_wallet
1207
+ FROM friends
1208
+ WHERE user_wallet = $3 AND status = 'accepted'
1209
+ )
1210
+ OR player_wallet IN (
1211
+ SELECT user_wallet
1212
+ FROM friends
1213
+ WHERE friend_wallet = $3 AND status = 'accepted'
1214
+ )
1215
+ )
1216
+ -- Check if any away team player is a friend (via friends table)
1217
+ OR
1218
+ EXISTS (
1219
+ SELECT 1
1220
+ FROM unnest(g.away_team_players) AS player_wallet
1221
+ WHERE player_wallet IN (
1222
+ SELECT friend_wallet
1223
+ FROM friends
1224
+ WHERE user_wallet = $3 AND status = 'accepted'
1225
+ )
1226
+ OR player_wallet IN (
1227
+ SELECT user_wallet
1228
+ FROM friends
1229
+ WHERE friend_wallet = $3 AND status = 'accepted'
1230
+ )
1231
+ )
1232
+ -- Check if any participant is a friend (via user_relationships table)
1233
+ OR
1234
+ EXISTS (
1235
+ SELECT 1
1236
+ FROM unnest(g.home_team_players || g.away_team_players || COALESCE(g.draw_team_players, '{}')) AS player_wallet
1237
+ JOIN users player_user ON player_user.wallet_address = player_wallet
1238
+ WHERE player_user.id IN (
1239
+ SELECT target_user_id
1240
+ FROM user_relationships
1241
+ WHERE user_id = $2 AND relationship_type = 'friend'
1242
+ )
1243
+ OR player_user.id IN (
1244
+ SELECT user_id
1245
+ FROM user_relationships
1246
+ WHERE target_user_id = $2 AND relationship_type = 'friend'
1247
+ )
1248
+ )
1249
+ )
1250
+ ORDER BY g.created_at DESC
1251
+ LIMIT 10
1252
+ `, [sportsEventId, userId, walletAddress]);
1253
+
1254
+ // Get game IDs already in myBets to avoid duplicates
1255
+ const myBetGameIds = new Set(myBets.map(b => b.gameId));
1256
+
1257
+ const friendBets = friendBetsResult.rows
1258
+ // Filter out games already in myBets (to avoid showing same game twice)
1259
+ .filter(row => !myBetGameIds.has(row.game_id))
1260
+ .map(row => ({
1261
+ gameId: row.game_id,
1262
+ gameAddress: row.game_address,
1263
+ title: row.title,
1264
+ imageUrl: row.image_url,
1265
+ matchupImageUrl: row.matchup_image_url,
1266
+ buyIn: parseFloat(row.buy_in),
1267
+ sportsEvent: row.sports_event,
1268
+ homeTeam: row.sports_event?.strHomeTeam,
1269
+ awayTeam: row.sports_event?.strAwayTeam,
1270
+ homeTeamBadge: row.sports_event?.strHomeTeamBadge,
1271
+ awayTeamBadge: row.sports_event?.strAwayTeamBadge,
1272
+ league: row.sports_event?.strLeague,
1273
+ strTimestamp: row.sports_event?.strTimestamp,
1274
+ homeTeamPlayers: row.home_team_players || [],
1275
+ awayTeamPlayers: row.away_team_players || [],
1276
+ drawTeamPlayers: row.draw_team_players || [],
1277
+ totalPlayers: (row.home_team_players?.length || 0) + (row.away_team_players?.length || 0) + (row.draw_team_players?.length || 0),
1278
+ lockTimestamp: row.lock_timestamp,
1279
+ isLocked: row.is_locked,
1280
+ isResolved: row.is_resolved,
1281
+ userAlreadyJoined: row.user_already_joined,
1282
+ creator: {
1283
+ walletAddress: row.creator_wallet,
1284
+ username: row.creator_username,
1285
+ avatar: row.creator_avatar,
1286
+ },
1287
+ creatorTeam: row.home_team_players?.includes(row.created_by) ? 'home' : row.away_team_players?.includes(row.created_by) ? 'away' : 'draw',
1288
+ }));
1289
+
1290
+ console.log(`[existingBets] Found ${friendBets.length} friend bet(s) for event ${sportsEventId}`);
1291
+
1292
+ // Debug: Log friend bet creators
1293
+ if (friendBets.length > 0) {
1294
+ console.log(`[existingBets] Friend bets from: ${friendBets.map(b => b.creator.username).join(', ')}`);
1295
+ }
1296
+
1297
+ // Query 3: Find PUBLIC bets (from non-friends) that user hasn't joined
1298
+ // These are joinable bets from anyone
1299
+ const friendBetGameIds = new Set(friendBets.map(b => b.gameId));
1300
+ const excludedGameIds = [...myBetGameIds, ...friendBetGameIds];
1301
+
1302
+ const publicBetsResult = await pool.query(`
1303
+ SELECT
1304
+ g.game_id,
1305
+ g.game_address,
1306
+ g.title,
1307
+ g.image_url,
1308
+ g.matchup_image_url,
1309
+ g.buy_in,
1310
+ g.sports_event,
1311
+ g.home_team_players,
1312
+ g.away_team_players,
1313
+ g.draw_team_players,
1314
+ g.lock_timestamp,
1315
+ g.is_locked,
1316
+ g.is_resolved,
1317
+ g.created_by,
1318
+ creator.username as creator_username,
1319
+ creator.avatar as creator_avatar,
1320
+ creator.wallet_address as creator_wallet
1321
+ FROM games g
1322
+ JOIN users creator ON g.created_by = creator.wallet_address
1323
+ WHERE
1324
+ (g.sports_event->>'idEvent' = $1 OR g.sports_event->>'pandascoreMatchId' = $1)
1325
+ AND g.is_resolved = false
1326
+ AND g.is_locked = false
1327
+ AND g.created_by != $2
1328
+ ${excludedGameIds.length > 0 ? `AND g.game_id NOT IN (${excludedGameIds.map((_, i) => `$${i + 3}`).join(', ')})` : ''}
1329
+ ORDER BY g.created_at DESC
1330
+ LIMIT 10
1331
+ `, [sportsEventId, walletAddress, ...excludedGameIds]);
1332
+
1333
+ const publicBets = publicBetsResult.rows.map(row => ({
1334
+ gameId: row.game_id,
1335
+ gameAddress: row.game_address,
1336
+ title: row.title,
1337
+ imageUrl: row.image_url,
1338
+ matchupImageUrl: row.matchup_image_url,
1339
+ buyIn: parseFloat(row.buy_in),
1340
+ sportsEvent: row.sports_event,
1341
+ homeTeam: row.sports_event?.strHomeTeam,
1342
+ awayTeam: row.sports_event?.strAwayTeam,
1343
+ homeTeamBadge: row.sports_event?.strHomeTeamBadge,
1344
+ awayTeamBadge: row.sports_event?.strAwayTeamBadge,
1345
+ league: row.sports_event?.strLeague,
1346
+ strTimestamp: row.sports_event?.strTimestamp,
1347
+ homeTeamPlayers: row.home_team_players || [],
1348
+ awayTeamPlayers: row.away_team_players || [],
1349
+ drawTeamPlayers: row.draw_team_players || [],
1350
+ totalPlayers: (row.home_team_players?.length || 0) + (row.away_team_players?.length || 0) + (row.draw_team_players?.length || 0),
1351
+ lockTimestamp: row.lock_timestamp,
1352
+ isLocked: row.is_locked,
1353
+ isResolved: row.is_resolved,
1354
+ creator: {
1355
+ walletAddress: row.creator_wallet,
1356
+ username: row.creator_username,
1357
+ avatar: row.creator_avatar,
1358
+ },
1359
+ creatorTeam: row.home_team_players?.includes(row.created_by) ? 'home' : row.away_team_players?.includes(row.created_by) ? 'away' : 'draw',
1360
+ }));
1361
+
1362
+ console.log(`[existingBets] Found ${publicBets.length} public bet(s) for event ${sportsEventId}`);
1363
+
1364
+ res.json({
1365
+ success: true,
1366
+ myBet,
1367
+ myBets, // All bets user is participating in
1368
+ friendBets,
1369
+ publicBets, // Joinable bets from non-friends
1370
+ hasMyBet: !!myBet,
1371
+ hasFriendBets: friendBets.length > 0,
1372
+ hasPublicBets: publicBets.length > 0,
1373
+ totalExistingBets: myBets.length + friendBets.length + publicBets.length,
1374
+ });
1375
+
1376
+ } catch (error) {
1377
+ console.error('[existingBets] Error:', error);
1378
+ res.status(500).json({
1379
+ success: false,
1380
+ error: error.message
1381
+ });
1382
+ }
1383
+ });
1384
+
1385
+ /**
1386
+ * GET /api/games/:gameId
1387
+ * Get game data by ID (public endpoint for sharing links)
1388
+ */
1389
+ router.get('/:gameId', async (req, res) => {
1390
+ try {
1391
+ const { gameId } = req.params;
1392
+ const { walletAddress } = req.query; // Optional: to get user-specific claim status
1393
+
1394
+ const result = await pool.query(`
1395
+ SELECT * FROM games WHERE game_id = $1
1396
+ `, [gameId]);
1397
+
1398
+ if (result.rows.length === 0) {
1399
+ return res.status(404).json({
1400
+ success: false,
1401
+ error: 'Game not found'
1402
+ });
1403
+ }
1404
+
1405
+ const game = result.rows[0];
1406
+
1407
+ // Build participants array from team players with user data
1408
+ const participants = [];
1409
+
1410
+ // Get user data for all participants AND creator
1411
+ const allWallets = [
1412
+ ...(game.home_team_players || []),
1413
+ ...(game.away_team_players || []),
1414
+ game.created_by // Include creator wallet
1415
+ ].filter(Boolean);
1416
+
1417
+ let creatorUsername = null;
1418
+ let creatorAvatar = null;
1419
+
1420
+ if (allWallets.length > 0) {
1421
+ const usersResult = await pool.query(
1422
+ 'SELECT id, wallet_address, username, avatar, telegram_user_id FROM users WHERE wallet_address = ANY($1)',
1423
+ [allWallets]
1424
+ );
1425
+
1426
+ const userMap = {};
1427
+ usersResult.rows.forEach(u => {
1428
+ userMap[u.wallet_address] = u;
1429
+ });
1430
+
1431
+ // Get creator info
1432
+ if (game.created_by && userMap[game.created_by]) {
1433
+ creatorUsername = userMap[game.created_by].username;
1434
+ creatorAvatar = userMap[game.created_by].avatar;
1435
+ }
1436
+
1437
+ // Build participants with team choice
1438
+ (game.home_team_players || []).forEach(wallet => {
1439
+ const user = userMap[wallet];
1440
+ participants.push({
1441
+ walletAddress: wallet,
1442
+ teamChoice: 'home',
1443
+ username: user?.username,
1444
+ telegramUserId: user?.telegram_user_id
1445
+ });
1446
+ });
1447
+
1448
+ (game.away_team_players || []).forEach(wallet => {
1449
+ const user = userMap[wallet];
1450
+ participants.push({
1451
+ walletAddress: wallet,
1452
+ teamChoice: 'away',
1453
+ username: user?.username,
1454
+ telegramUserId: user?.telegram_user_id
1455
+ });
1456
+ });
1457
+ }
1458
+
1459
+ // Extract team info from sportsEvent for convenience
1460
+ const sportsEvent = game.sports_event;
1461
+
1462
+ // If walletAddress provided, get user's claim status
1463
+ let userClaimData = null;
1464
+ if (walletAddress) {
1465
+ const userRefResult = await pool.query(
1466
+ 'SELECT claimed_at, claim_signature, amount_claimed, team_choice FROM user_game_refs WHERE wallet_address = $1 AND game_id = $2',
1467
+ [walletAddress, gameId]
1468
+ );
1469
+ if (userRefResult.rows.length > 0) {
1470
+ const ref = userRefResult.rows[0];
1471
+ userClaimData = {
1472
+ claimedAt: ref.claimed_at,
1473
+ claimSignature: ref.claim_signature,
1474
+ amountClaimed: ref.amount_claimed ? parseFloat(ref.amount_claimed) : null,
1475
+ teamChoice: ref.team_choice,
1476
+ };
1477
+ }
1478
+ }
1479
+
1480
+ res.status(200).json({
1481
+ gameId: game.game_id,
1482
+ gameAddress: game.game_address,
1483
+ title: game.title,
1484
+ imageUrl: game.image_url,
1485
+ matchupImageUrl: game.matchup_image_url, // Pre-generated matchup image
1486
+ gameType: game.game_type,
1487
+ buyIn: parseFloat(game.buy_in),
1488
+ maxPlayers: game.max_players,
1489
+ gameMode: game.game_mode,
1490
+ createdBy: game.created_by,
1491
+ creatorWallet: game.created_by, // created_by IS the creator wallet
1492
+ creatorUsername: creatorUsername,
1493
+ creatorAvatar: creatorAvatar,
1494
+ sportsEvent: sportsEvent,
1495
+ // Top-level team info for easy access (extracted from sportsEvent)
1496
+ homeTeam: sportsEvent?.strHomeTeam,
1497
+ awayTeam: sportsEvent?.strAwayTeam,
1498
+ league: sportsEvent?.strLeague,
1499
+ homeTeamBadge: sportsEvent?.strHomeTeamBadge,
1500
+ awayTeamBadge: sportsEvent?.strAwayTeamBadge,
1501
+ strTimestamp: sportsEvent?.strTimestamp,
1502
+ homeTeamPlayers: game.home_team_players || [],
1503
+ awayTeamPlayers: game.away_team_players || [],
1504
+ drawTeamPlayers: game.draw_team_players || [],
1505
+ participants: participants,
1506
+ // Pool amounts for pari-mutuel display
1507
+ // For hybrid games (legacy + pari-mutuel), calculate correct totals
1508
+ ...calculatePoolAmounts(game),
1509
+ playerAmounts: game.player_amounts || {},
1510
+ lockTimestamp: game.lock_timestamp,
1511
+ isLocked: game.is_locked,
1512
+ isResolved: game.is_resolved,
1513
+ automaticStatus: game.automatic_status,
1514
+ createdAt: game.created_at,
1515
+ // User-specific claim data (if walletAddress was provided)
1516
+ claimedAt: userClaimData?.claimedAt || null,
1517
+ claimSignature: userClaimData?.claimSignature || null,
1518
+ amountClaimed: userClaimData?.amountClaimed || null,
1519
+ userTeamChoice: userClaimData?.teamChoice || null,
1520
+ });
1521
+
1522
+ } catch (error) {
1523
+ console.error('[getGame] Error:', error);
1524
+ res.status(500).json({
1525
+ success: false,
1526
+ error: error.message
1527
+ });
1528
+ }
1529
+ });
1530
+
1531
+ /**
1532
+ * POST /api/auth/games/:gameId/claim
1533
+ * Mark a game as claimed by the user
1534
+ */
1535
+ router.post('/:gameId/claim', async (req, res) => {
1536
+ try {
1537
+ const { gameId } = req.params;
1538
+ const { walletAddress, claimedAt, claimSignature, claimExplorerUrl, amountClaimed } = req.body;
1539
+
1540
+ if (!walletAddress) {
1541
+ return res.status(400).json({
1542
+ success: false,
1543
+ error: 'Wallet address is required'
1544
+ });
1545
+ }
1546
+
1547
+ console.log(`[claimGame] Marking game ${gameId} as claimed by ${walletAddress}`);
1548
+
1549
+ // Update user's game reference with claim information
1550
+ const result = await pool.query(`
1551
+ UPDATE user_game_refs
1552
+ SET claimed_at = $1,
1553
+ claim_signature = $2,
1554
+ claim_explorer_url = $3,
1555
+ amount_claimed = $4,
1556
+ updated_at = NOW()
1557
+ WHERE wallet_address = $5 AND game_id = $6
1558
+ RETURNING *
1559
+ `, [claimedAt, claimSignature, claimExplorerUrl, amountClaimed, walletAddress, gameId]);
1560
+
1561
+ if (result.rows.length === 0) {
1562
+ console.log(`[claimGame] âš ī¸ No user_game_ref found for ${walletAddress} in game ${gameId}`);
1563
+ return res.status(404).json({
1564
+ success: false,
1565
+ error: 'User game reference not found - did you join this game?'
1566
+ });
1567
+ }
1568
+
1569
+ // Also mark the game as resolved in the games table
1570
+ await pool.query(`
1571
+ UPDATE games SET is_resolved = TRUE, updated_at = NOW() WHERE game_id = $1
1572
+ `, [gameId]);
1573
+
1574
+ console.log(`[claimGame] Game ${gameId} marked as claimed for user ${walletAddress}`);
1575
+
1576
+ // Update Redis cache for this user's game
1577
+ gamesCacheService.updateGame(walletAddress, gameId, {
1578
+ claimedAt: claimedAt || new Date().toISOString(),
1579
+ claimSignature: claimSignature || null,
1580
+ claimExplorerUrl: claimExplorerUrl || null,
1581
+ amountClaimed: amountClaimed || null,
1582
+ }).catch(err => console.warn('[claimGame] Redis cache update failed:', err.message));
1583
+
1584
+ // Broadcast claim event to all connected clients
1585
+ if (chatNamespace) {
1586
+ chatNamespace.emit('game:claimed', {
1587
+ gameId: gameId,
1588
+ walletAddress: walletAddress,
1589
+ amountClaimed: amountClaimed,
1590
+ claimedAt: claimedAt || new Date().toISOString(),
1591
+ timestamp: Date.now()
1592
+ });
1593
+ console.log(`[claimGame] 📡 Broadcasted game:claimed event`);
1594
+ }
1595
+
1596
+ res.status(200).json({
1597
+ success: true,
1598
+ message: 'Game marked as claimed'
1599
+ });
1600
+
1601
+ } catch (error) {
1602
+ console.error('[claimGame] Error:', error);
1603
+ res.status(500).json({
1604
+ success: false,
1605
+ error: error.message
1606
+ });
1607
+ }
1608
+ });
1609
+
1610
+ /**
1611
+ * POST /api/games/notify-join
1612
+ * Send notification to ALL game participants when someone joins
1613
+ */
1614
+ router.post('/notify-join', async (req, res) => {
1615
+ try {
1616
+ const { creatorWallet, joinerWallet, joinerUsername, teamChoice, gameInvite, betAmount } = req.body;
1617
+
1618
+ if (!joinerWallet || !gameInvite?.gameId) {
1619
+ return res.status(400).json({
1620
+ success: false,
1621
+ error: 'Joiner wallet and gameId are required'
1622
+ });
1623
+ }
1624
+
1625
+ // Use the shared pool connection (already has SSL configured)
1626
+ const notifPool = pool;
1627
+
1628
+ // Get joiner's user ID
1629
+ const joinerResult = await notifPool.query(
1630
+ 'SELECT id FROM users WHERE wallet_address = $1',
1631
+ [joinerWallet]
1632
+ );
1633
+ const joinerUserId = joinerResult.rows.length > 0 ? joinerResult.rows[0].id : null;
1634
+
1635
+ // Get all current participants from the game (BEFORE the joiner was added)
1636
+ // We'll notify everyone except the joiner themselves
1637
+ const gameResult = await notifPool.query(
1638
+ 'SELECT home_team_players, away_team_players, draw_team_players, created_by FROM games WHERE game_id = $1',
1639
+ [gameInvite.gameId]
1640
+ );
1641
+
1642
+ if (gameResult.rows.length === 0) {
1643
+ console.log(`[notifyJoin] âš ī¸ Game ${gameInvite.gameId} not found in database`);
1644
+ return res.status(404).json({
1645
+ success: false,
1646
+ error: 'Game not found'
1647
+ });
1648
+ }
1649
+
1650
+ const game = gameResult.rows[0];
1651
+
1652
+ // Collect all participant wallet addresses (including creator)
1653
+ const allParticipantWallets = new Set([
1654
+ ...(game.home_team_players || []),
1655
+ ...(game.away_team_players || []),
1656
+ ...(game.draw_team_players || []),
1657
+ game.created_by // Always include creator
1658
+ ].filter(Boolean));
1659
+
1660
+ // Remove the joiner from the set (don't notify them about their own join)
1661
+ allParticipantWallets.delete(joinerWallet);
1662
+
1663
+ console.log(`[notifyJoin] đŸ“Ŧ Notifying ${allParticipantWallets.size} participant(s) about ${joinerUsername} joining game ${gameInvite.gameId}`);
1664
+
1665
+ if (allParticipantWallets.size === 0) {
1666
+ console.log('[notifyJoin] No other participants to notify');
1667
+ return res.status(200).json({
1668
+ success: true,
1669
+ message: 'No participants to notify (first joiner or creator only)'
1670
+ });
1671
+ }
1672
+
1673
+ // Get user IDs for all participants
1674
+ const participantsResult = await notifPool.query(
1675
+ 'SELECT id, wallet_address, username FROM users WHERE wallet_address = ANY($1)',
1676
+ [Array.from(allParticipantWallets)]
1677
+ );
1678
+
1679
+ const participantMap = {};
1680
+ participantsResult.rows.forEach(u => {
1681
+ participantMap[u.wallet_address] = { id: u.id, username: u.username };
1682
+ });
1683
+
1684
+ // Track notification results
1685
+ const notificationResults = {
1686
+ sent: [],
1687
+ failed: [],
1688
+ skipped: []
1689
+ };
1690
+
1691
+ // Send notifications to each participant
1692
+ const { forwardChatNotification } = require('../services/telegramNotifications');
1693
+
1694
+ for (const participantWallet of allParticipantWallets) {
1695
+ const participant = participantMap[participantWallet];
1696
+
1697
+ if (!participant) {
1698
+ console.log(`[notifyJoin] â­ī¸ Participant ${participantWallet.slice(0, 8)} not found in users table - skipping`);
1699
+ notificationResults.skipped.push(participantWallet);
1700
+ continue;
1701
+ }
1702
+
1703
+ const participantUserId = participant.id;
1704
+ const isCreator = participantWallet === game.created_by;
1705
+
1706
+ try {
1707
+ // Enrich gameInvite with S3 URL from games table
1708
+ const enrichedGameInvite = await enrichGameInviteWithS3Url(notifPool, gameInvite);
1709
+
1710
+ // Create notification in database
1711
+ const insertResult = await notifPool.query(
1712
+ `INSERT INTO chat_notifications (
1713
+ user_id, sender_user_id, notification_type, notification_data, read, created_at
1714
+ ) VALUES ($1, $2, 'game_joined', $3, false, NOW())
1715
+ RETURNING id, created_at`,
1716
+ [
1717
+ participantUserId,
1718
+ joinerUserId,
1719
+ JSON.stringify({
1720
+ joinerUsername,
1721
+ teamChoice,
1722
+ gameInvite: enrichedGameInvite, // Use enriched gameInvite with S3 URL
1723
+ isCreator, // Flag to customize message on frontend if needed
1724
+ amount: betAmount, // Actual bet amount for pari-mutuel
1725
+ })
1726
+ ]
1727
+ );
1728
+
1729
+ const notificationId = insertResult.rows[0].id;
1730
+ const notificationCreatedAt = insertResult.rows[0].created_at;
1731
+
1732
+ console.log(`đŸ“Ŧ Sent game_joined notification to ${isCreator ? 'creator' : 'participant'} ${participantWallet.slice(0, 8)} (ID: ${notificationId})`);
1733
+
1734
+ // Send real-time notification via WebSocket
1735
+ if (chatNamespace) {
1736
+ const notification = {
1737
+ id: notificationId,
1738
+ type: 'game_joined',
1739
+ senderUsername: joinerUsername,
1740
+ senderWallet: joinerWallet,
1741
+ message: teamChoice,
1742
+ gameInvite: enrichedGameInvite,
1743
+ createdAt: notificationCreatedAt.toISOString(),
1744
+ read: false,
1745
+ amount: betAmount, // Actual bet amount for pari-mutuel
1746
+ };
1747
+
1748
+ chatNamespace.to(`user-${participantUserId}`).emit('notification', notification);
1749
+ console.log(`🔔 Real-time notification sent to user-${participantUserId} (${participant.username || participantWallet.slice(0, 8)})`);
1750
+ }
1751
+
1752
+ // Cache notification to Redis (non-blocking)
1753
+ notificationCacheService.cacheNotification(participantUserId, {
1754
+ id: notificationId,
1755
+ type: 'game_joined',
1756
+ read: false,
1757
+ messageId: null,
1758
+ message: teamChoice,
1759
+ senderUsername: joinerUsername,
1760
+ senderWallet: joinerWallet,
1761
+ senderAvatar: null,
1762
+ createdAt: notificationCreatedAt,
1763
+ gameInvite: enrichedGameInvite,
1764
+ amount: betAmount, // Actual bet amount for pari-mutuel
1765
+ }).catch(err => console.error('[gamesRoutes] Failed to cache game_joined notification:', err.message));
1766
+
1767
+ // Forward to Telegram if participant has it connected (with CTA button)
1768
+ try {
1769
+ const teamName = teamChoice === 'home'
1770
+ ? (gameInvite.homeTeam?.split(' ').pop() || 'Home')
1771
+ : (gameInvite.awayTeam?.split(' ').pop() || 'Away');
1772
+
1773
+ // Customize message based on whether they're the creator or another participant
1774
+ const message = isCreator
1775
+ ? `${joinerUsername} joined your ${gameInvite.title} bet! They're backing ${teamName}`
1776
+ : `${joinerUsername} joined the ${gameInvite.title} bet you're in! They're backing ${teamName}`;
1777
+
1778
+ // Pass gameId in metadata for the CTA button
1779
+ await forwardChatNotification(notifPool, participantUserId, 'game_joined', joinerUsername, message, { gameId: gameInvite.gameId });
1780
+ console.log(`📱 Telegram notification sent to ${participant.username || participantWallet.slice(0, 8)} with CTA`);
1781
+ } catch (telegramError) {
1782
+ console.log(`âš ī¸ Telegram forward failed for ${participantWallet.slice(0, 8)}:`, telegramError.message);
1783
+ }
1784
+
1785
+ notificationResults.sent.push(participantWallet);
1786
+
1787
+ } catch (notifError) {
1788
+ console.error(`[notifyJoin] ❌ Failed to notify ${participantWallet.slice(0, 8)}:`, notifError.message);
1789
+ notificationResults.failed.push(participantWallet);
1790
+ }
1791
+ }
1792
+
1793
+ console.log(`[notifyJoin] ✅ Complete: ${notificationResults.sent.length} sent, ${notificationResults.failed.length} failed, ${notificationResults.skipped.length} skipped`);
1794
+
1795
+ res.status(200).json({
1796
+ success: true,
1797
+ message: `Notifications sent to ${notificationResults.sent.length} participant(s)`,
1798
+ results: notificationResults
1799
+ });
1800
+
1801
+ } catch (error) {
1802
+ console.error('[notifyJoin] Error:', error);
1803
+ res.status(500).json({
1804
+ success: false,
1805
+ error: error.message
1806
+ });
1807
+ }
1808
+ });
1809
+
1810
+ /**
1811
+ * POST /api/games/invite
1812
+ * Send game invitation to a friend
1813
+ */
1814
+ router.post('/invite', async (req, res) => {
1815
+ try {
1816
+ const { inviterWallet, inviteeWallet, inviterUsername, gameInvite } = req.body;
1817
+
1818
+ if (!inviterWallet || !inviteeWallet || !gameInvite) {
1819
+ return res.status(400).json({
1820
+ success: false,
1821
+ error: 'Inviter wallet, invitee wallet, and game invite data are required'
1822
+ });
1823
+ }
1824
+
1825
+ // Don't allow inviting yourself
1826
+ if (inviterWallet === inviteeWallet) {
1827
+ return res.status(400).json({
1828
+ success: false,
1829
+ error: 'Cannot invite yourself'
1830
+ });
1831
+ }
1832
+
1833
+ // Use the shared pool connection
1834
+ const notifPool = pool;
1835
+
1836
+ // Get inviter's user ID and username
1837
+ const inviterResult = await notifPool.query(
1838
+ 'SELECT id, username FROM users WHERE wallet_address = $1',
1839
+ [inviterWallet]
1840
+ );
1841
+
1842
+ if (inviterResult.rows.length === 0) {
1843
+ return res.status(404).json({
1844
+ success: false,
1845
+ error: 'Inviter not found'
1846
+ });
1847
+ }
1848
+
1849
+ const inviterUserId = inviterResult.rows[0].id;
1850
+ // Use database username, fall back to passed-in username, then wallet
1851
+ const resolvedInviterUsername = inviterResult.rows[0].username || inviterUsername || inviterWallet.slice(0, 8);
1852
+
1853
+ // Get invitee's user ID
1854
+ const inviteeResult = await notifPool.query(
1855
+ 'SELECT id FROM users WHERE wallet_address = $1',
1856
+ [inviteeWallet]
1857
+ );
1858
+
1859
+ if (inviteeResult.rows.length === 0) {
1860
+ return res.status(404).json({
1861
+ success: false,
1862
+ error: 'Invitee not found - they may not have an account yet'
1863
+ });
1864
+ }
1865
+
1866
+ const inviteeUserId = inviteeResult.rows[0].id;
1867
+
1868
+ // Enrich gameInvite with S3 URL from games table
1869
+ const enrichedGameInvite = await enrichGameInviteWithS3Url(notifPool, gameInvite);
1870
+
1871
+ // Create notification and get the inserted ID
1872
+ const insertResult = await notifPool.query(
1873
+ `INSERT INTO chat_notifications (
1874
+ user_id, sender_user_id, notification_type, notification_data, read, created_at
1875
+ ) VALUES ($1, $2, 'game_invite', $3, false, NOW())
1876
+ RETURNING id, created_at`,
1877
+ [
1878
+ inviteeUserId,
1879
+ inviterUserId,
1880
+ JSON.stringify({
1881
+ gameInvite: enrichedGameInvite, // Use enriched gameInvite with S3 URL
1882
+ })
1883
+ ]
1884
+ );
1885
+
1886
+ const notificationId = insertResult.rows[0].id;
1887
+ const notificationCreatedAt = insertResult.rows[0].created_at;
1888
+
1889
+ console.log(`📨 Sent game_invite notification from ${inviterWallet.slice(0, 8)} to ${inviteeWallet.slice(0, 8)} (ID: ${notificationId})`);
1890
+
1891
+ // Send real-time notification via WebSocket
1892
+ if (chatNamespace) {
1893
+ const notification = {
1894
+ id: notificationId, // ✅ Use actual database ID for duplicate detection
1895
+ type: 'game_invite',
1896
+ senderUsername: resolvedInviterUsername,
1897
+ senderWallet: inviterWallet,
1898
+ message: '', // Empty for game invites
1899
+ gameInvite: enrichedGameInvite, // Use enriched gameInvite with S3 URL
1900
+ createdAt: notificationCreatedAt.toISOString(),
1901
+ read: false,
1902
+ };
1903
+
1904
+ console.log('[gameInvite] Emitting to user-' + inviteeUserId, notification);
1905
+ // Emit to invitee's socket
1906
+ chatNamespace.to(`user-${inviteeUserId}`).emit('notification', notification);
1907
+ console.log(`🔔 Real-time game invite sent to user-${inviteeUserId}`);
1908
+ } else {
1909
+ console.warn('âš ī¸ chatNamespace not available - notification will only appear after refresh');
1910
+ }
1911
+
1912
+ // Cache notification to Redis (non-blocking)
1913
+ notificationCacheService.cacheNotification(inviteeUserId, {
1914
+ id: notificationId,
1915
+ type: 'game_invite',
1916
+ read: false,
1917
+ messageId: null,
1918
+ message: '',
1919
+ senderUsername: resolvedInviterUsername,
1920
+ senderWallet: inviterWallet,
1921
+ senderAvatar: null,
1922
+ createdAt: notificationCreatedAt,
1923
+ gameInvite: enrichedGameInvite,
1924
+ }).catch(err => console.error('[gamesRoutes] Failed to cache game_invite notification:', err.message));
1925
+
1926
+ // Forward to Telegram if invitee has it connected (with CTA button)
1927
+ try {
1928
+ const { forwardChatNotification } = require('../services/telegramNotifications');
1929
+ const message = `${resolvedInviterUsername} invited you to join their ${gameInvite.title} bet! ${gameInvite.buyIn} SOL buy-in`;
1930
+ // Pass gameId in metadata for the CTA button
1931
+ await forwardChatNotification(notifPool, inviteeUserId, 'game_invite', resolvedInviterUsername, message, { gameId: gameInvite.gameId });
1932
+ console.log('📱 Game invite notification forwarded to Telegram with CTA button');
1933
+ } catch (telegramError) {
1934
+ console.log('âš ī¸ Telegram forward failed:', telegramError.message);
1935
+ }
1936
+
1937
+ // For Connect4 games, update the invited_player field to make this a private game
1938
+ // This ensures only the invited player sees the game in "Waiting for You" tab
1939
+ if (gameInvite.gameType === 'connect4' && gameInvite.gameId) {
1940
+ try {
1941
+ const updateResult = await notifPool.query(
1942
+ `UPDATE games
1943
+ SET invited_player = $1, updated_at = NOW()
1944
+ WHERE game_id = $2 AND invited_player IS NULL`,
1945
+ [inviteeWallet, gameInvite.gameId]
1946
+ );
1947
+ if (updateResult.rowCount > 0) {
1948
+ console.log(`🔴🟡 [Connect4] Set invited_player=${inviteeWallet.slice(0, 8)} for game ${gameInvite.gameId}`);
1949
+ } else {
1950
+ console.log(`🔴🟡 [Connect4] Game ${gameInvite.gameId} already has an invited_player set`);
1951
+ }
1952
+ } catch (updateErr) {
1953
+ console.error(`[Connect4] Failed to update invited_player:`, updateErr.message);
1954
+ // Don't fail the invite - notification was still sent
1955
+ }
1956
+ }
1957
+
1958
+ // For Connect4 games, emit a connect4_game_created event so invitee's Connect4 tab auto-refreshes
1959
+ if (gameInvite.gameType === 'connect4' && chatNamespace) {
1960
+ chatNamespace.to(`user-${inviteeUserId}`).emit('connect4_update', {
1961
+ type: 'connect4_game_created',
1962
+ gameId: gameInvite.gameId,
1963
+ creatorUsername: resolvedInviterUsername,
1964
+ buyIn: gameInvite.buyIn,
1965
+ });
1966
+ console.log(`🔴🟡 [Connect4] Emitted connect4_game_created to user-${inviteeUserId} for game ${gameInvite.gameId}`);
1967
+ }
1968
+
1969
+ res.status(200).json({
1970
+ success: true,
1971
+ message: 'Invitation sent'
1972
+ });
1973
+
1974
+ } catch (error) {
1975
+ console.error('[gameInvite] Error:', error);
1976
+ res.status(500).json({
1977
+ success: false,
1978
+ error: error.message
1979
+ });
1980
+ }
1981
+ });
1982
+
1983
+ /**
1984
+ * GET /api/games/:gameId/invites
1985
+ * Get list of wallet addresses that have been invited to this game
1986
+ */
1987
+ router.get('/:gameId/invites', async (req, res) => {
1988
+ try {
1989
+ const { gameId } = req.params;
1990
+
1991
+ if (!gameId) {
1992
+ return res.status(400).json({
1993
+ success: false,
1994
+ error: 'Game ID is required'
1995
+ });
1996
+ }
1997
+
1998
+ // Query notifications table for game_invite notifications for this game
1999
+ // The gameId is nested inside gameInvite object in notification_data
2000
+ const result = await pool.query(`
2001
+ SELECT DISTINCT u.wallet_address
2002
+ FROM chat_notifications cn
2003
+ JOIN users u ON cn.user_id = u.id
2004
+ WHERE cn.notification_type = 'game_invite'
2005
+ AND cn.notification_data->'gameInvite'->>'gameId' = $1
2006
+ `, [gameId]);
2007
+
2008
+ const invitedWallets = result.rows.map(row => row.wallet_address);
2009
+
2010
+ console.log(`[gameInvites] Found ${invitedWallets.length} invited wallets for game ${gameId}`);
2011
+
2012
+ res.status(200).json({
2013
+ success: true,
2014
+ gameId,
2015
+ invitedWallets
2016
+ });
2017
+
2018
+ } catch (error) {
2019
+ console.error('[gameInvites] Error:', error);
2020
+ res.status(500).json({
2021
+ success: false,
2022
+ error: error.message
2023
+ });
2024
+ }
2025
+ });
2026
+
2027
+ /**
2028
+ * POST /api/audit/log
2029
+ * Log audit events
2030
+ */
2031
+ router.post('/audit', async (req, res) => {
2032
+ try {
2033
+ const { type, method, userId, metadata } = req.body;
2034
+
2035
+ await pool.query(`
2036
+ INSERT INTO audit_logs (log_type, method, user_id, metadata)
2037
+ VALUES ($1, $2, $3, $4)
2038
+ `, [type, method, userId, JSON.stringify(metadata)]);
2039
+
2040
+ console.log(`[audit] Logged ${type} for user ${userId}`);
2041
+
2042
+ res.status(200).json({
2043
+ success: true,
2044
+ message: 'Event logged'
2045
+ });
2046
+
2047
+ } catch (error) {
2048
+ console.error('[audit] Error:', error);
2049
+ res.status(500).json({
2050
+ success: false,
2051
+ error: error.message
2052
+ });
2053
+ }
2054
+ });
2055
+
2056
+ /**
2057
+ * GET /api/games
2058
+ * Get all games (used by oracle for notification checking)
2059
+ */
2060
+ router.get('/', async (req, res) => {
2061
+ try {
2062
+ // Get all automatic (gameMode = 4) games that are not resolved
2063
+ const result = await pool.query(`
2064
+ SELECT * FROM games
2065
+ WHERE game_mode IN (4, 5)
2066
+ AND is_resolved = false
2067
+ ORDER BY created_at DESC
2068
+ `);
2069
+
2070
+ const games = result.rows.map(row => {
2071
+ const sportsEvent = row.sports_event;
2072
+ return {
2073
+ gameId: row.game_id,
2074
+ gameAddress: row.game_address,
2075
+ title: row.title,
2076
+ imageUrl: row.image_url,
2077
+ matchupImageUrl: row.matchup_image_url, // Pre-generated S3 matchup image
2078
+ gameType: row.game_type,
2079
+ buyIn: parseFloat(row.buy_in),
2080
+ maxPlayers: row.max_players,
2081
+ gameMode: row.game_mode,
2082
+ createdBy: row.created_by,
2083
+ sportsEvent: sportsEvent,
2084
+ // Top-level team info for easy access
2085
+ homeTeam: sportsEvent?.strHomeTeam,
2086
+ awayTeam: sportsEvent?.strAwayTeam,
2087
+ league: sportsEvent?.strLeague,
2088
+ homeTeamBadge: sportsEvent?.strHomeTeamBadge,
2089
+ awayTeamBadge: sportsEvent?.strAwayTeamBadge,
2090
+ strTimestamp: sportsEvent?.strTimestamp,
2091
+ homeTeamPlayers: row.home_team_players || [],
2092
+ awayTeamPlayers: row.away_team_players || [],
2093
+ drawTeamPlayers: row.draw_team_players || [],
2094
+ participants: [
2095
+ ...(row.home_team_players || []).map(wallet => ({ walletAddress: wallet, teamChoice: 'home' })),
2096
+ ...(row.away_team_players || []).map(wallet => ({ walletAddress: wallet, teamChoice: 'away' })),
2097
+ ...(row.draw_team_players || []).map(wallet => ({ walletAddress: wallet, teamChoice: 'draw' }))
2098
+ ],
2099
+ lockTimestamp: row.lock_timestamp,
2100
+ lockTime: row.lock_timestamp ? { _seconds: row.lock_timestamp } : null,
2101
+ isLocked: row.is_locked,
2102
+ isResolved: row.is_resolved,
2103
+ automaticStatus: row.automatic_status,
2104
+ createdAt: row.created_at,
2105
+ lockNotificationSent_10min: row.lock_notification_sent_10min || false,
2106
+ lockNotificationSent_now: row.lock_notification_sent_now || false,
2107
+ };
2108
+ });
2109
+
2110
+ console.log(`[getAllGames] Found ${games.length} unresolved automatic game(s)`);
2111
+
2112
+ res.status(200).json({
2113
+ success: true,
2114
+ games: games
2115
+ });
2116
+
2117
+ } catch (error) {
2118
+ console.error('[getAllGames] Error:', error);
2119
+ res.status(500).json({
2120
+ success: false,
2121
+ error: error.message
2122
+ });
2123
+ }
2124
+ });
2125
+
2126
+ /**
2127
+ * GET /api/games/automatic/pending
2128
+ * Get pending automatic games (oracle endpoint)
2129
+ */
2130
+ router.get('/automatic/pending', async (req, res) => {
2131
+ try {
2132
+ // Get all automatic (gameMode = 4) games that are:
2133
+ // 1. Not resolved yet (is_resolved = false)
2134
+ // 2. Game has started (lock_timestamp < now)
2135
+ // 3. Status is still 'pending' or 'locked' or 'in_progress'
2136
+ const result = await pool.query(`
2137
+ SELECT * FROM games
2138
+ WHERE game_mode IN (4, 5)
2139
+ AND is_resolved = false
2140
+ AND (automatic_status IN ('pending', 'locked', 'in_progress') OR automatic_status IS NULL)
2141
+ ORDER BY created_at DESC
2142
+ `);
2143
+
2144
+ const games = result.rows.map(row => {
2145
+ const sportsEvent = row.sports_event;
2146
+ return {
2147
+ gameId: row.game_id,
2148
+ gameAddress: row.game_address,
2149
+ title: row.title,
2150
+ imageUrl: row.image_url,
2151
+ matchupImageUrl: row.matchup_image_url, // Pre-generated S3 matchup image
2152
+ gameType: row.game_type,
2153
+ buyIn: parseFloat(row.buy_in),
2154
+ maxPlayers: row.max_players,
2155
+ gameMode: row.game_mode,
2156
+ createdBy: row.created_by,
2157
+ sportsEvent: sportsEvent,
2158
+ // Top-level team info for easy access
2159
+ homeTeam: sportsEvent?.strHomeTeam,
2160
+ awayTeam: sportsEvent?.strAwayTeam,
2161
+ league: sportsEvent?.strLeague,
2162
+ homeTeamBadge: sportsEvent?.strHomeTeamBadge,
2163
+ awayTeamBadge: sportsEvent?.strAwayTeamBadge,
2164
+ strTimestamp: sportsEvent?.strTimestamp,
2165
+ homeTeamPlayers: row.home_team_players || [],
2166
+ awayTeamPlayers: row.away_team_players || [],
2167
+ drawTeamPlayers: row.draw_team_players || [],
2168
+ lockTimestamp: row.lock_timestamp,
2169
+ lockTime: row.lock_timestamp ? { _seconds: row.lock_timestamp } : null, // Oracle expects this format
2170
+ isLocked: row.is_locked,
2171
+ isResolved: row.is_resolved,
2172
+ automaticStatus: row.automatic_status,
2173
+ createdAt: row.created_at,
2174
+ };
2175
+ });
2176
+
2177
+ console.log(`[getPendingGames] Found ${games.length} pending automatic game(s)`);
2178
+
2179
+ res.status(200).json({
2180
+ success: true,
2181
+ games: games
2182
+ });
2183
+
2184
+ } catch (error) {
2185
+ console.error('[getPendingGames] Error:', error);
2186
+ res.status(500).json({
2187
+ success: false,
2188
+ error: error.message
2189
+ });
2190
+ }
2191
+ });
2192
+
2193
+ /**
2194
+ * POST /api/games/:gameId/update-notification-flags
2195
+ * Update notification sent flags (oracle endpoint)
2196
+ */
2197
+ router.post('/:gameId/update-notification-flags', async (req, res) => {
2198
+ try {
2199
+ const { gameId } = req.params;
2200
+ const { lockNotificationSent_10min, lockNotificationSent_now } = req.body;
2201
+
2202
+ const updates = [];
2203
+ const values = [];
2204
+ let paramIndex = 1;
2205
+
2206
+ if (lockNotificationSent_10min !== undefined) {
2207
+ updates.push(`lock_notification_sent_10min = $${paramIndex}`);
2208
+ values.push(lockNotificationSent_10min);
2209
+ paramIndex++;
2210
+ }
2211
+
2212
+ if (lockNotificationSent_now !== undefined) {
2213
+ updates.push(`lock_notification_sent_now = $${paramIndex}`);
2214
+ values.push(lockNotificationSent_now);
2215
+ paramIndex++;
2216
+ }
2217
+
2218
+ if (updates.length === 0) {
2219
+ return res.json({ success: true, message: 'No updates to make' });
2220
+ }
2221
+
2222
+ values.push(gameId);
2223
+
2224
+ await pool.query(`
2225
+ UPDATE games
2226
+ SET ${updates.join(', ')},
2227
+ updated_at = NOW()
2228
+ WHERE game_id = $${paramIndex}
2229
+ `, values);
2230
+
2231
+ console.log(`[updateNotificationFlags] Updated flags for ${gameId}:`, req.body);
2232
+
2233
+ res.json({
2234
+ success: true,
2235
+ message: 'Notification flags updated'
2236
+ });
2237
+
2238
+ } catch (error) {
2239
+ console.error('[updateNotificationFlags] Error:', error);
2240
+ res.status(500).json({
2241
+ success: false,
2242
+ error: error.message
2243
+ });
2244
+ }
2245
+ });
2246
+
2247
+ /**
2248
+ * POST /api/games/:gameId/lock
2249
+ * Lock a game when it starts (oracle endpoint)
2250
+ */
2251
+ router.post('/:gameId/lock', async (req, res) => {
2252
+ try {
2253
+ const { gameId } = req.params;
2254
+ const { lockedAt, lockedBy } = req.body;
2255
+
2256
+ console.log(`[lockGame] 🔒 Locking game: ${gameId}`);
2257
+
2258
+ // Update game status in PostgreSQL
2259
+ const result = await pool.query(`
2260
+ UPDATE games
2261
+ SET
2262
+ is_locked = true,
2263
+ automatic_status = 'locked',
2264
+ updated_at = NOW()
2265
+ WHERE game_id = $1 AND is_locked = false
2266
+ RETURNING *
2267
+ `, [gameId]);
2268
+
2269
+ if (result.rows.length === 0) {
2270
+ console.log(`[lockGame] â„šī¸ Game already locked or not found: ${gameId}`);
2271
+ return res.status(200).json({
2272
+ success: true,
2273
+ message: 'Game already locked or not found'
2274
+ });
2275
+ }
2276
+
2277
+ console.log(`[lockGame] ✅ Successfully locked game ${gameId}`);
2278
+
2279
+ // Update Redis cache for all players in this game
2280
+ const lockedGame = result.rows[0];
2281
+ const allPlayers = [
2282
+ ...(lockedGame.home_team_players || []),
2283
+ ...(lockedGame.away_team_players || []),
2284
+ ...(lockedGame.draw_team_players || []),
2285
+ ];
2286
+ gamesCacheService.updateGameForAllUsers(gameId, allPlayers, {
2287
+ isLocked: true,
2288
+ automaticStatus: 'locked',
2289
+ }).catch(err => console.warn('[lockGame] Redis cache update failed:', err.message));
2290
+
2291
+ // Broadcast game locked event to all connected clients
2292
+ if (chatNamespace) {
2293
+ chatNamespace.emit('game:locked', {
2294
+ gameId: gameId,
2295
+ isLocked: true,
2296
+ lockedAt: lockedAt || new Date().toISOString(),
2297
+ lockedBy: lockedBy || 'oracle',
2298
+ timestamp: Date.now()
2299
+ });
2300
+ console.log(`[lockGame] 📡 Broadcasted game:locked event to all clients`);
2301
+ }
2302
+
2303
+ res.status(200).json({
2304
+ success: true,
2305
+ message: 'Game locked successfully',
2306
+ game: result.rows[0]
2307
+ });
2308
+
2309
+ } catch (error) {
2310
+ console.error('[lockGame] ❌ Error:', error);
2311
+ res.status(500).json({
2312
+ success: false,
2313
+ error: error.message
2314
+ });
2315
+ }
2316
+ });
2317
+
2318
+ /**
2319
+ * POST /api/games/:gameId/resolve
2320
+ * Resolve a game (oracle endpoint)
2321
+ */
2322
+ router.post('/:gameId/resolve', async (req, res) => {
2323
+ try {
2324
+ const { gameId } = req.params;
2325
+ const { winner, homeScore, awayScore, resolvedAt, resolvedBy, resolveSignature } = req.body;
2326
+
2327
+ console.log(`[resolveGame] đŸŽ¯ Attempting to resolve game: ${gameId}`);
2328
+ console.log(`[resolveGame] Winner: ${winner === null ? 'null (REFUND)' : winner}, Score: ${homeScore}-${awayScore}`);
2329
+ console.log(`[resolveGame] Resolve signature: ${resolveSignature || 'NOT PROVIDED âš ī¸'}`);
2330
+
2331
+ // Note: winner can be null for refund scenarios (no competition)
2332
+ if (winner === undefined) {
2333
+ console.error('[resolveGame] ❌ No winner provided (undefined)');
2334
+ return res.status(400).json({
2335
+ success: false,
2336
+ error: 'Winner is required (use null for refunds)'
2337
+ });
2338
+ }
2339
+
2340
+ // First check if game exists
2341
+ const checkResult = await pool.query(`
2342
+ SELECT game_id, is_resolved, automatic_status FROM games WHERE game_id = $1
2343
+ `, [gameId]);
2344
+
2345
+ console.log(`[resolveGame] 📊 Found ${checkResult.rows.length} game(s) with ID: ${gameId}`);
2346
+ if (checkResult.rows.length > 0) {
2347
+ console.log(`[resolveGame] Current status: is_resolved=${checkResult.rows[0].is_resolved}, automatic_status=${checkResult.rows[0].automatic_status}`);
2348
+ }
2349
+
2350
+ // Update game status in PostgreSQL (including claim_signature for transaction linking)
2351
+ const result = await pool.query(`
2352
+ UPDATE games
2353
+ SET
2354
+ is_resolved = true,
2355
+ automatic_status = 'resolved',
2356
+ claim_signature = COALESCE($3, claim_signature),
2357
+ sports_event = COALESCE(sports_event, '{}'::jsonb) || $1::jsonb,
2358
+ updated_at = NOW()
2359
+ WHERE game_id = $2
2360
+ RETURNING *
2361
+ `, [
2362
+ JSON.stringify({
2363
+ finalScore: {
2364
+ winner,
2365
+ homeScore,
2366
+ awayScore,
2367
+ resolvedAt: resolvedAt || new Date().toISOString(),
2368
+ resolvedBy: resolvedBy || 'oracle'
2369
+ }
2370
+ }),
2371
+ gameId,
2372
+ resolveSignature || null
2373
+ ]);
2374
+
2375
+ if (resolveSignature) {
2376
+ console.log(`[resolveGame] 💾 Saved claim_signature: ${resolveSignature.slice(0, 20)}...`);
2377
+ }
2378
+
2379
+ if (result.rows.length === 0) {
2380
+ console.error(`[resolveGame] ❌ Game not found in UPDATE: ${gameId}`);
2381
+ return res.status(404).json({
2382
+ success: false,
2383
+ error: 'Game not found'
2384
+ });
2385
+ }
2386
+
2387
+ console.log(`[resolveGame] ✅ Successfully resolved game ${gameId} - Winner: ${winner} (${homeScore}-${awayScore})`);
2388
+ console.log(`[resolveGame] Updated status: is_resolved=${result.rows[0].is_resolved}, automatic_status=${result.rows[0].automatic_status}`);
2389
+
2390
+ // Update Redis cache for all players in this game
2391
+ const resolvedGame = result.rows[0];
2392
+ const resolveAllPlayers = [
2393
+ ...(resolvedGame.home_team_players || []),
2394
+ ...(resolvedGame.away_team_players || []),
2395
+ ...(resolvedGame.draw_team_players || []),
2396
+ ];
2397
+ gamesCacheService.updateGameForAllUsers(gameId, resolveAllPlayers, {
2398
+ isResolved: true,
2399
+ automaticStatus: 'resolved',
2400
+ finalScoreWinner: winner,
2401
+ finalScoreHome: homeScore,
2402
+ finalScoreAway: awayScore,
2403
+ }).catch(err => console.warn('[resolveGame] Redis cache update failed:', err.message));
2404
+
2405
+ // đŸŽ¯ Update winners' user_game_refs with claim_signature for transaction history linking
2406
+ if (resolveSignature && winner !== null) {
2407
+ try {
2408
+ // Determine winning team column based on winner
2409
+ const winnerColumn = winner === 'home' ? 'home_team_players'
2410
+ : winner === 'away' ? 'away_team_players'
2411
+ : winner === 'draw' ? 'draw_team_players' : null;
2412
+
2413
+ if (winnerColumn) {
2414
+ const winnersResult = await pool.query(`
2415
+ UPDATE user_game_refs
2416
+ SET claim_signature = $1, updated_at = NOW()
2417
+ WHERE game_id = $2
2418
+ AND wallet_address = ANY(
2419
+ SELECT unnest(${winnerColumn}) FROM games WHERE game_id = $2
2420
+ )
2421
+ RETURNING wallet_address
2422
+ `, [resolveSignature, gameId]);
2423
+
2424
+ console.log(`[resolveGame] đŸŽ¯ Updated ${winnersResult.rows.length} winner(s) with claim_signature`);
2425
+ }
2426
+ } catch (winnerUpdateError) {
2427
+ console.error('[resolveGame] âš ī¸ Error updating winners claim_signature:', winnerUpdateError.message);
2428
+ }
2429
+ }
2430
+
2431
+ // 💰 Process referral commission for GAME CREATOR's referrer ONLY
2432
+ // The person who referred the game creator earns 1% of the ENTIRE POT
2433
+ // Other players' referrers get NOTHING from this game
2434
+ try {
2435
+ // Get total pot size from game
2436
+ const potResult = await pool.query(`
2437
+ SELECT COALESCE(SUM(buy_in), 0) as total_pot
2438
+ FROM user_game_refs
2439
+ WHERE game_id = $1
2440
+ `, [gameId]);
2441
+
2442
+ const potSizeSOL = parseFloat(potResult.rows[0].total_pot) || 0;
2443
+ const potSizeLamports = Math.floor(potSizeSOL * 1_000_000_000);
2444
+
2445
+ if (potSizeLamports > 0) {
2446
+ const gameType = result.rows[0].game_type || 'sports';
2447
+ const referralResult = await referralEarningsService.processGameCommissions(
2448
+ gameId,
2449
+ gameType,
2450
+ potSizeLamports
2451
+ );
2452
+
2453
+ if (referralResult.commissions.length > 0) {
2454
+ const commission = referralResult.commissions[0]; // Only one commission per game
2455
+ console.log(`[resolveGame] 💰 Game creator's referrer earns ${commission.commissionSOL.toFixed(4)} SOL (1% of ${potSizeSOL} SOL pot)`);
2456
+
2457
+ // Notify the referrer in real-time via WebSocket
2458
+ if (chatNamespace && global.onlineUsers) {
2459
+ // Look up referrer's user ID
2460
+ const referrerUserResult = await pool.query(
2461
+ 'SELECT id FROM users WHERE wallet_address = $1',
2462
+ [commission.referrerWallet]
2463
+ );
2464
+
2465
+ if (referrerUserResult.rows.length > 0) {
2466
+ const referrerUserId = referrerUserResult.rows[0].id;
2467
+ const targetSocketId = global.onlineUsers.get(referrerUserId);
2468
+
2469
+ if (targetSocketId) {
2470
+ chatNamespace.to(targetSocketId).emit('referral:earning', {
2471
+ gameId: gameId,
2472
+ gameCreatorUsername: commission.refereeUsername,
2473
+ commissionSOL: commission.commissionSOL,
2474
+ potSizeSOL: potSizeSOL,
2475
+ message: `You earned ${commission.commissionSOL.toFixed(4)} SOL from ${commission.refereeUsername}'s game! (1% of ${potSizeSOL} SOL pot)`,
2476
+ timestamp: Date.now()
2477
+ });
2478
+ console.log(`[resolveGame] 📡 Sent referral:earning notification to ${commission.referrerWallet.slice(0, 8)}`);
2479
+ }
2480
+ }
2481
+ }
2482
+ } else {
2483
+ console.log(`[resolveGame] 💰 No referral commission - game creator has no referrer`);
2484
+ }
2485
+ }
2486
+ } catch (referralError) {
2487
+ // Don't fail the resolution if referral processing fails
2488
+ console.error('[resolveGame] âš ī¸ Error processing referral commission (game still resolved):', referralError.message);
2489
+ }
2490
+
2491
+ // Broadcast game resolved event to all connected clients
2492
+ if (chatNamespace) {
2493
+ chatNamespace.emit('game:resolved', {
2494
+ gameId: gameId,
2495
+ isResolved: true,
2496
+ winner: winner,
2497
+ homeScore: homeScore,
2498
+ awayScore: awayScore,
2499
+ resolvedAt: resolvedAt || new Date().toISOString(),
2500
+ resolvedBy: resolvedBy || 'oracle',
2501
+ timestamp: Date.now()
2502
+ });
2503
+ console.log(`[resolveGame] 📡 Broadcasted game:resolved event to all clients`);
2504
+ }
2505
+
2506
+ res.status(200).json({
2507
+ success: true,
2508
+ message: 'Game resolved successfully',
2509
+ game: result.rows[0]
2510
+ });
2511
+
2512
+ } catch (error) {
2513
+ console.error('[resolveGame] ❌ Error:', error);
2514
+ res.status(500).json({
2515
+ success: false,
2516
+ error: error.message
2517
+ });
2518
+ }
2519
+ });
2520
+
2521
+ /**
2522
+ * GET /api/auth/games/user/:walletAddress
2523
+ * Get games for a user.
2524
+ *
2525
+ * Pagination (opt-in): pass ?limit=10 to enable paginated mode.
2526
+ * - Redis-first: serves from cache with zero DB hits.
2527
+ * - Falls back to PostgreSQL with LIMIT on cache miss, then warms cache.
2528
+ * - Returns { games, nextCursor, hasMore }
2529
+ *
2530
+ * Without ?limit: returns ALL games (legacy behavior for other callers).
2531
+ */
2532
+ router.get('/user/:walletAddress', async (req, res) => {
2533
+ try {
2534
+ const { walletAddress } = req.params;
2535
+ const rawLimit = req.query.limit;
2536
+ const paginated = rawLimit !== undefined;
2537
+ const limit = paginated ? Math.min(parseInt(rawLimit) || 10, 50) : null;
2538
+ const cursor = req.query.cursor || null;
2539
+
2540
+ // 1. Paginated mode: try Redis cache first
2541
+ if (paginated) {
2542
+ const cached = await gamesCacheService.getGames(walletAddress, { limit, cursor });
2543
+ if (cached) {
2544
+ console.log(`[getUserGames] Cache hit for ${walletAddress.slice(0, 8)}... (${cached.games.length} games)`);
2545
+ return res.status(200).json({
2546
+ success: true,
2547
+ games: cached.games,
2548
+ nextCursor: cached.nextCursor,
2549
+ hasMore: cached.hasMore,
2550
+ });
2551
+ }
2552
+ }
2553
+
2554
+ // 2. PostgreSQL query (with optional LIMIT for paginated mode)
2555
+ if (paginated) {
2556
+ console.log(`[getUserGames] Cache miss for ${walletAddress.slice(0, 8)}... — querying PostgreSQL (limit=${limit})`);
2557
+ }
2558
+
2559
+ let queryText = `
2560
+ SELECT
2561
+ ugr.*,
2562
+ g.game_address,
2563
+ g.title,
2564
+ g.image_url,
2565
+ g.matchup_image_url,
2566
+ g.game_type,
2567
+ g.buy_in,
2568
+ g.max_players,
2569
+ g.game_mode,
2570
+ g.created_by,
2571
+ g.sports_event,
2572
+ g.home_team_players,
2573
+ g.away_team_players,
2574
+ g.draw_team_players,
2575
+ g.lock_timestamp,
2576
+ g.is_locked,
2577
+ g.is_resolved,
2578
+ g.automatic_status,
2579
+ g.connect4_winner,
2580
+ g.game_status
2581
+ FROM user_game_refs ugr
2582
+ LEFT JOIN games g ON ugr.game_id = g.game_id
2583
+ WHERE ugr.wallet_address = $1
2584
+ `;
2585
+ const queryParams = [walletAddress];
2586
+
2587
+ if (paginated && cursor) {
2588
+ queryText += ` AND ugr.joined_at < $2 ORDER BY ugr.joined_at DESC LIMIT $3`;
2589
+ queryParams.push(new Date(parseInt(cursor)).toISOString(), limit + 1);
2590
+ } else if (paginated) {
2591
+ queryText += ` ORDER BY ugr.joined_at DESC LIMIT $2`;
2592
+ queryParams.push(limit + 1);
2593
+ } else {
2594
+ queryText += ` ORDER BY ugr.joined_at DESC`;
2595
+ }
2596
+
2597
+ const result = await pool.query(queryText, queryParams);
2598
+
2599
+ // Determine hasMore (only relevant in paginated mode)
2600
+ const hasMore = paginated ? result.rows.length > limit : false;
2601
+ const rows = hasMore ? result.rows.slice(0, limit) : result.rows;
2602
+
2603
+ // For billiards games, we need to fetch players from user_game_refs
2604
+ const billiardsGameIds = rows
2605
+ .filter(row => row.game_type === 'billiards')
2606
+ .map(row => row.game_id);
2607
+
2608
+ let billiardsPlayersMap = {};
2609
+ if (billiardsGameIds.length > 0) {
2610
+ const playersResult = await pool.query(`
2611
+ SELECT
2612
+ ugr.game_id,
2613
+ ugr.wallet_address,
2614
+ u.username,
2615
+ u.avatar
2616
+ FROM user_game_refs ugr
2617
+ LEFT JOIN users u ON ugr.wallet_address = u.wallet_address
2618
+ WHERE ugr.game_id = ANY($1)
2619
+ `, [billiardsGameIds]);
2620
+
2621
+ playersResult.rows.forEach(row => {
2622
+ if (!billiardsPlayersMap[row.game_id]) {
2623
+ billiardsPlayersMap[row.game_id] = [];
2624
+ }
2625
+ billiardsPlayersMap[row.game_id].push({
2626
+ walletAddress: row.wallet_address,
2627
+ username: row.username || row.wallet_address?.slice(0, 6),
2628
+ avatar: row.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${row.wallet_address}`,
2629
+ });
2630
+ });
2631
+ }
2632
+
2633
+ const games = rows.map(row => {
2634
+ const sportsEvent = row.sports_event;
2635
+ let finalScore = sportsEvent?.finalScore || null;
2636
+
2637
+ let connect4Winner = null;
2638
+ if (row.game_type === 'connect4' && row.connect4_winner) {
2639
+ connect4Winner = row.connect4_winner;
2640
+ finalScore = {
2641
+ winner: row.connect4_winner,
2642
+ homeScore: row.connect4_winner === 'home' ? 1 : 0,
2643
+ awayScore: row.connect4_winner === 'away' ? 1 : 0,
2644
+ };
2645
+ }
2646
+
2647
+ let gameStatus = row.status;
2648
+ if (row.game_type === 'connect4') {
2649
+ if (row.game_status === 'cancelled') {
2650
+ gameStatus = 'cancelled';
2651
+ } else if (row.game_status === 'completed' || row.connect4_winner) {
2652
+ gameStatus = 'completed';
2653
+ } else if (row.game_status === 'playing' || row.game_status === 'in_progress') {
2654
+ gameStatus = 'playing';
2655
+ } else {
2656
+ gameStatus = 'waiting';
2657
+ }
2658
+ }
2659
+
2660
+ const isBilliards = row.game_type === 'billiards';
2661
+ const players = isBilliards ? (billiardsPlayersMap[row.game_id] || []) : [];
2662
+
2663
+ return {
2664
+ gameId: row.game_id,
2665
+ gameAddress: row.game_address,
2666
+ title: row.title,
2667
+ imageUrl: row.image_url,
2668
+ matchupImageUrl: row.matchup_image_url,
2669
+ gameType: row.game_type,
2670
+ buyIn: parseFloat(row.buy_in),
2671
+ maxPlayers: row.max_players,
2672
+ gameMode: row.game_mode,
2673
+ createdBy: row.created_by,
2674
+ sportsEvent: sportsEvent,
2675
+ homeTeam: sportsEvent?.strHomeTeam,
2676
+ awayTeam: sportsEvent?.strAwayTeam,
2677
+ league: sportsEvent?.strLeague,
2678
+ homeTeamBadge: sportsEvent?.strHomeTeamBadge,
2679
+ awayTeamBadge: sportsEvent?.strAwayTeamBadge,
2680
+ strTimestamp: sportsEvent?.strTimestamp,
2681
+ homeTeamPlayers: row.home_team_players || [],
2682
+ awayTeamPlayers: row.away_team_players || [],
2683
+ drawTeamPlayers: row.draw_team_players || [],
2684
+ players: players,
2685
+ roomName: isBilliards ? row.title : undefined,
2686
+ lockTimestamp: row.lock_timestamp,
2687
+ isLocked: row.is_locked,
2688
+ isResolved: row.is_resolved,
2689
+ automaticStatus: row.automatic_status,
2690
+ finalScore: finalScore,
2691
+ winner: connect4Winner,
2692
+ status: gameStatus,
2693
+ role: row.role,
2694
+ joinedAt: row.joined_at,
2695
+ teamChoice: row.team_choice,
2696
+ mySignature: row.my_signature,
2697
+ myExplorerUrl: row.my_explorer_url,
2698
+ walletType: row.wallet_type,
2699
+ claimedAt: row.claimed_at,
2700
+ claimSignature: row.claim_signature,
2701
+ claimExplorerUrl: row.claim_explorer_url,
2702
+ amountClaimed: row.amount_claimed ? parseFloat(row.amount_claimed) : null,
2703
+ };
2704
+ });
2705
+
2706
+ // 3. Warm cache in background (non-blocking) — only in paginated mode on first page
2707
+ if (paginated && !cursor && games.length > 0) {
2708
+ gamesCacheService.warmCache(walletAddress, games)
2709
+ .catch(err => console.warn('[getUserGames] Cache warm failed:', err.message));
2710
+ }
2711
+
2712
+ // 4. Compute nextCursor from last game's joinedAt timestamp
2713
+ let nextCursor = null;
2714
+ if (hasMore && games.length > 0) {
2715
+ const lastGame = games[games.length - 1];
2716
+ nextCursor = new Date(lastGame.joinedAt).getTime().toString();
2717
+ }
2718
+
2719
+ // Return response — include pagination fields only when paginated
2720
+ const response = { success: true, games };
2721
+ if (paginated) {
2722
+ response.nextCursor = nextCursor;
2723
+ response.hasMore = hasMore;
2724
+ }
2725
+ res.status(200).json(response);
2726
+
2727
+ } catch (error) {
2728
+ console.error('[getUserGames] Error:', error);
2729
+ res.status(500).json({
2730
+ success: false,
2731
+ error: error.message
2732
+ });
2733
+ }
2734
+ });
2735
+
2736
+ /**
2737
+ * GET /api/games/:gameId/state
2738
+ * Get game state including current user's participation (protected endpoint)
2739
+ */
2740
+ router.get('/:gameId/state', async (req, res) => {
2741
+ try {
2742
+ const { gameId } = req.params;
2743
+ const walletAddress = req.headers['x-wallet-address']; // Optional
2744
+
2745
+ // Get game data
2746
+ const gameResult = await pool.query('SELECT * FROM games WHERE game_id = $1', [gameId]);
2747
+
2748
+ if (gameResult.rows.length === 0) {
2749
+ return res.status(404).json({
2750
+ success: false,
2751
+ error: 'Game not found'
2752
+ });
2753
+ }
2754
+
2755
+ const game = gameResult.rows[0];
2756
+
2757
+ // Build response
2758
+ const response = {
2759
+ gameId: game.game_id,
2760
+ isLocked: game.is_locked,
2761
+ isResolved: game.is_resolved,
2762
+ homeTeamCount: game.home_team_players?.length || 0,
2763
+ awayTeamCount: game.away_team_players?.length || 0,
2764
+ drawTeamCount: game.draw_team_players?.length || 0,
2765
+ totalPlayers: (game.home_team_players?.length || 0) + (game.away_team_players?.length || 0) + (game.draw_team_players?.length || 0),
2766
+ totalPool: parseFloat(game.total_pool) || 0,
2767
+ automaticStatus: game.automatic_status,
2768
+ };
2769
+
2770
+ // If resolved, include winner and scores
2771
+ if (game.is_resolved && game.sports_event?.finalScore) {
2772
+ response.winner = game.sports_event.finalScore.winner;
2773
+ response.homeScore = game.sports_event.finalScore.homeScore;
2774
+ response.awayScore = game.sports_event.finalScore.awayScore;
2775
+ }
2776
+
2777
+ // If wallet address provided, include user's participation
2778
+ if (walletAddress) {
2779
+ const userRefResult = await pool.query(
2780
+ 'SELECT team_choice, claimed_at FROM user_game_refs WHERE wallet_address = $1 AND game_id = $2',
2781
+ [walletAddress, gameId]
2782
+ );
2783
+
2784
+ if (userRefResult.rows.length > 0) {
2785
+ response.userTeamChoice = userRefResult.rows[0].team_choice;
2786
+ response.userParticipated = true;
2787
+ response.userClaimed = userRefResult.rows[0].claimed_at !== null; // Check if claimed
2788
+
2789
+ // Determine if user won
2790
+ if (game.is_resolved && game.sports_event?.finalScore) {
2791
+ response.userWon = userRefResult.rows[0].team_choice === game.sports_event.finalScore.winner;
2792
+ }
2793
+ } else {
2794
+ response.userParticipated = false;
2795
+ }
2796
+ }
2797
+
2798
+ res.json(response);
2799
+ } catch (error) {
2800
+ console.error('[getGameState] Error:', error);
2801
+ res.status(500).json({
2802
+ success: false,
2803
+ error: error.message
2804
+ });
2805
+ }
2806
+ });
2807
+
2808
+ /**
2809
+ * POST /api/games/notify-participant
2810
+ * Send notification to a game participant (used by oracle for game start notifications)
2811
+ */
2812
+ router.post('/notify-participant', async (req, res) => {
2813
+ try {
2814
+ const { walletAddress, notificationType, message, gameInvite, finalScore } = req.body;
2815
+
2816
+ if (!walletAddress || !notificationType) {
2817
+ return res.status(400).json({
2818
+ success: false,
2819
+ error: 'Missing required fields: walletAddress, notificationType'
2820
+ });
2821
+ }
2822
+
2823
+ // Get user ID from wallet address
2824
+ const userResult = await pool.query(
2825
+ 'SELECT id, username FROM users WHERE wallet_address = $1',
2826
+ [walletAddress]
2827
+ );
2828
+
2829
+ if (userResult.rows.length === 0) {
2830
+ // User not registered in web app, skip
2831
+ return res.json({
2832
+ success: true,
2833
+ message: 'User not registered, notification skipped'
2834
+ });
2835
+ }
2836
+
2837
+ const userId = userResult.rows[0].id;
2838
+ const username = userResult.rows[0].username;
2839
+
2840
+ // Enrich gameInvite with S3 URL from games table
2841
+ const enrichedGameInvite = gameInvite
2842
+ ? await enrichGameInviteWithS3Url(pool, gameInvite)
2843
+ : null;
2844
+
2845
+ // Build notification data
2846
+ const notificationData = {
2847
+ message,
2848
+ gameInvite: enrichedGameInvite, // Use enriched gameInvite with S3 URL
2849
+ };
2850
+
2851
+ // Add finalScore for game result notifications
2852
+ if (finalScore) {
2853
+ notificationData.finalScore = finalScore;
2854
+ }
2855
+
2856
+ console.log(`[notify-participant] Creating ${notificationType} notification:`, {
2857
+ userId,
2858
+ username,
2859
+ hasGameInvite: !!gameInvite,
2860
+ gameInviteTitle: gameInvite?.title,
2861
+ hasFinalScore: !!finalScore
2862
+ });
2863
+
2864
+ // Insert notification into database
2865
+ const notifResult = await pool.query(
2866
+ `INSERT INTO chat_notifications (
2867
+ user_id, notification_type, notification_data, read, created_at
2868
+ ) VALUES ($1, $2, $3, false, NOW())
2869
+ RETURNING id`,
2870
+ [
2871
+ userId,
2872
+ notificationType,
2873
+ JSON.stringify(notificationData)
2874
+ ]
2875
+ );
2876
+
2877
+ const notificationId = notifResult.rows[0].id;
2878
+
2879
+ // Send real-time notification via WebSocket
2880
+ if (chatNamespace) {
2881
+ const notification = {
2882
+ id: notificationId,
2883
+ type: notificationType,
2884
+ senderUsername: 'Dubs', // System notification
2885
+ senderWallet: 'system',
2886
+ message: message,
2887
+ gameInvite: enrichedGameInvite, // Use enriched gameInvite with S3 URL
2888
+ createdAt: new Date().toISOString(),
2889
+ read: false,
2890
+ };
2891
+
2892
+ // Add finalScore for game result notifications
2893
+ if (finalScore) {
2894
+ notification.finalScore = finalScore;
2895
+ }
2896
+
2897
+ chatNamespace.to(`user-${userId}`).emit('notification', notification);
2898
+ console.log(`🔔 ${notificationType} notification sent to user-${userId} (${username})`);
2899
+ }
2900
+
2901
+ // Cache notification to Redis (non-blocking)
2902
+ notificationCacheService.cacheNotification(userId, {
2903
+ id: notificationId,
2904
+ type: notificationType,
2905
+ read: false,
2906
+ messageId: null,
2907
+ message: message,
2908
+ senderUsername: 'Dubs',
2909
+ senderWallet: 'system',
2910
+ senderAvatar: null,
2911
+ createdAt: new Date(),
2912
+ gameInvite: enrichedGameInvite,
2913
+ finalScore: finalScore || null,
2914
+ }).catch(err => console.error(`[gamesRoutes] Failed to cache ${notificationType} notification:`, err.message));
2915
+
2916
+ res.json({
2917
+ success: true,
2918
+ notificationId
2919
+ });
2920
+
2921
+ } catch (error) {
2922
+ console.error('Error sending participant notification:', error);
2923
+ res.status(500).json({
2924
+ success: false,
2925
+ error: error.message
2926
+ });
2927
+ }
2928
+ });
2929
+
2930
+ /**
2931
+ * GET /api/games/:gameId/creator-referrer
2932
+ * Get the referrer wallet of the game creator (for on-chain commission payout)
2933
+ *
2934
+ * This is called by the oracle when resolving a game to determine
2935
+ * if the game creator was referred by someone who should receive 1% commission.
2936
+ *
2937
+ * Returns:
2938
+ * - referrerWallet: The wallet address of the referrer (or null)
2939
+ * - creatorWallet: The game creator's wallet
2940
+ * - referrerUsername: The referrer's username (for logging)
2941
+ */
2942
+ router.get('/:gameId/creator-referrer', async (req, res) => {
2943
+ const { gameId } = req.params;
2944
+ const logPrefix = `[CREATOR-REFERRER:${gameId.slice(-8)}]`;
2945
+
2946
+ console.log(`${logPrefix} 🔍 Incoming request to find game creator's referrer`);
2947
+ console.log(`${logPrefix} Full gameId: ${gameId}`);
2948
+
2949
+ try {
2950
+ // Query to find the game creator and their referrer
2951
+ // 1. Find the game's creator (created_by wallet)
2952
+ // 2. Join to users table to find who referred the creator
2953
+ console.log(`${logPrefix} Executing database query...`);
2954
+ const result = await pool.query(`
2955
+ SELECT
2956
+ g.game_id,
2957
+ g.created_by as creator_wallet,
2958
+ creator.id as creator_user_id,
2959
+ creator.username as creator_username,
2960
+ creator.referral_code as used_referral_code,
2961
+ referrer.id as referrer_user_id,
2962
+ referrer.wallet_address as referrer_wallet,
2963
+ referrer.username as referrer_username,
2964
+ referrer.my_referral_code as referrer_my_code
2965
+ FROM games g
2966
+ JOIN users creator ON g.created_by = creator.wallet_address
2967
+ LEFT JOIN users referrer ON creator.referral_code = referrer.my_referral_code
2968
+ WHERE g.game_id = $1
2969
+ `, [gameId]);
2970
+
2971
+ console.log(`${logPrefix} Query returned ${result.rows.length} row(s)`);
2972
+
2973
+ if (result.rows.length === 0) {
2974
+ console.log(`${logPrefix} âš ī¸ Game not found or creator not registered in users table`);
2975
+ return res.json({
2976
+ success: true,
2977
+ referrerWallet: null,
2978
+ message: 'Game not found or creator not registered'
2979
+ });
2980
+ }
2981
+
2982
+ const row = result.rows[0];
2983
+ console.log(`${logPrefix} Game creator: ${row.creator_username || 'unknown'} (${row.creator_wallet?.slice(0, 8)}...)`);
2984
+ console.log(`${logPrefix} Creator's referral_code: ${row.used_referral_code || 'NULL'}`);
2985
+
2986
+ if (!row.referrer_wallet) {
2987
+ console.log(`${logPrefix} â„šī¸ Creator has no referrer`);
2988
+ if (row.used_referral_code) {
2989
+ console.log(`${logPrefix} âš ī¸ Creator has referral_code="${row.used_referral_code}" but no matching referrer found!`);
2990
+ console.log(`${logPrefix} This could mean: referrer deleted, code typo, or referrer's my_referral_code changed`);
2991
+ } else {
2992
+ console.log(`${logPrefix} Creator never signed up with a referral code`);
2993
+ }
2994
+ return res.json({
2995
+ success: true,
2996
+ referrerWallet: null,
2997
+ creatorWallet: row.creator_wallet,
2998
+ creatorUsername: row.creator_username,
2999
+ message: 'Game creator has no referrer'
3000
+ });
3001
+ }
3002
+
3003
+ console.log(`${logPrefix} ✅ FOUND REFERRER!`);
3004
+ console.log(`${logPrefix} Referrer: ${row.referrer_username || 'unknown'} (${row.referrer_wallet})`);
3005
+ console.log(`${logPrefix} Referrer's my_referral_code: ${row.referrer_my_code}`);
3006
+ console.log(`${logPrefix} Match: creator.referral_code(${row.used_referral_code}) = referrer.my_referral_code(${row.referrer_my_code})`);
3007
+
3008
+ res.json({
3009
+ success: true,
3010
+ referrerWallet: row.referrer_wallet,
3011
+ referrerUsername: row.referrer_username,
3012
+ creatorWallet: row.creator_wallet,
3013
+ creatorUsername: row.creator_username
3014
+ });
3015
+
3016
+ } catch (error) {
3017
+ console.error(`${logPrefix} ❌ ERROR in creator-referrer lookup:`);
3018
+ console.error(`${logPrefix} Message: ${error.message}`);
3019
+ console.error(`${logPrefix} Stack: ${error.stack}`);
3020
+ res.status(500).json({
3021
+ success: false,
3022
+ error: error.message
3023
+ });
3024
+ }
3025
+ });
3026
+
3027
+ module.exports = router;
3028
+