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,1596 @@
1
+ /**
2
+ * Automatic Sports Game Oracle Service
3
+ * Monitors pending automatic games and resolves them based on real sports scores
4
+ */
5
+
6
+ const { Connection, Keypair, PublicKey, Transaction, TransactionInstruction } = require('@solana/web3.js');
7
+ const axios = require('axios');
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const promoService = require('./promoService');
11
+
12
+ // 🔐 HARDCODED OPERATOR WALLET - Must match the contract's hardcoded address!
13
+ const OPERATOR_WALLET = new PublicKey('BVZXwZpfgyzTBdRFHohkHZppPHnAyqyctRsKy3vWfQib');
14
+
15
+ /**
16
+ * Normalize league names from TheSportsDB to standard codes (MLB, NBA, NFL, NHL, EPL)
17
+ * TheSportsDB returns full names like "Major League Baseball" instead of "MLB"
18
+ */
19
+ function normalizeLeague(league) {
20
+ if (!league) return 'NHL';
21
+
22
+ const normalized = league.toUpperCase();
23
+
24
+ // Already normalized
25
+ if (['MLB', 'NBA', 'NFL', 'NHL', 'EPL', 'UFC', 'NCAAB', 'NCAAF'].includes(normalized)) {
26
+ return normalized;
27
+ }
28
+
29
+ // Map full names to short codes
30
+ const leagueMap = {
31
+ 'MAJOR LEAGUE BASEBALL': 'MLB',
32
+ 'NATIONAL BASKETBALL ASSOCIATION': 'NBA',
33
+ 'NATIONAL FOOTBALL LEAGUE': 'NFL',
34
+ 'NATIONAL HOCKEY LEAGUE': 'NHL',
35
+ 'ENGLISH PREMIER LEAGUE': 'EPL',
36
+ 'PREMIER LEAGUE': 'EPL',
37
+ // Also handle common variations
38
+ 'AMERICAN LEAGUE': 'MLB',
39
+ 'NATIONAL LEAGUE': 'MLB',
40
+ // UFC variations
41
+ 'ULTIMATE FIGHTING CHAMPIONSHIP': 'UFC',
42
+ 'FIGHTING': 'UFC',
43
+ 'MMA': 'UFC',
44
+ };
45
+
46
+ return leagueMap[normalized] || 'NHL';
47
+ }
48
+
49
+ class AutomaticGameOracle {
50
+ constructor(config) {
51
+ this.connection = new Connection(config.rpcUrl, 'confirmed');
52
+ this.programId = new PublicKey(config.programId);
53
+ this.oracleKeypair = config.oracleKeypair;
54
+ this.liveScoresApiUrl = config.liveScoresApiUrl;
55
+ this.dubsServerUrl = config.dubsServerUrl; // PostgreSQL database (only source now)
56
+ this.checkIntervalMs = config.checkIntervalMs || 60 * 1000; // 1 minute default
57
+ this.notifyBeforeMinutes = config.notifyBeforeMinutes || 10; // Notify 10 minutes before by default
58
+ this.isRunning = false;
59
+ this.intervalId = null;
60
+ }
61
+
62
+ /**
63
+ * Start the oracle monitor (cron-like)
64
+ */
65
+ start() {
66
+ if (this.isRunning) {
67
+ console.log('⚠️ Oracle already running');
68
+ return;
69
+ }
70
+
71
+ console.log('🤖 Starting Automatic Game Oracle...');
72
+ console.log(` Check interval: ${this.checkIntervalMs / 1000}s`);
73
+ console.log(` Oracle wallet: ${this.oracleKeypair.publicKey.toString()}`);
74
+ console.log(` 📊 PostgreSQL Server: ${this.dubsServerUrl}`);
75
+ console.log(` 🏀 Live Scores API: ${this.liveScoresApiUrl}`);
76
+ console.log(` ⏰ Notify before: ${this.notifyBeforeMinutes} minutes`);
77
+
78
+ this.isRunning = true;
79
+
80
+ // Run immediately
81
+ this.checkPendingGames();
82
+
83
+ // Then run on interval
84
+ this.intervalId = setInterval(() => {
85
+ this.checkPendingGames();
86
+ }, this.checkIntervalMs);
87
+ }
88
+
89
+ /**
90
+ * Stop the oracle monitor
91
+ */
92
+ stop() {
93
+ if (this.intervalId) {
94
+ clearInterval(this.intervalId);
95
+ this.intervalId = null;
96
+ }
97
+ this.isRunning = false;
98
+ console.log('🛑 Oracle stopped');
99
+ }
100
+
101
+ /**
102
+ * Main loop: Check all pending games and resolve if completed
103
+ */
104
+ async checkPendingGames() {
105
+ try {
106
+ console.log(`\n🔍 [${new Date().toISOString()}] Checking pending automatic games...`);
107
+
108
+ // First check for games approaching lock time (for notifications)
109
+ console.log(' ⏰ Checking for games approaching lock time...');
110
+ await this.checkUpcomingGames();
111
+
112
+ // Get pending games from dubs-server (PostgreSQL only)
113
+ console.log(` 📡 Querying: ${this.dubsServerUrl}/api/games/automatic/pending`);
114
+
115
+ const response = await axios.get(
116
+ `${this.dubsServerUrl}/api/games/automatic/pending`,
117
+ { timeout: 10000 }
118
+ );
119
+
120
+ if (!response.data.success || !response.data.games) {
121
+ console.log(' ℹ️ No pending games found');
122
+ return;
123
+ }
124
+
125
+ const pendingGames = response.data.games;
126
+ console.log(` ✅ Found ${pendingGames.length} pending game(s) from PostgreSQL`);
127
+
128
+ // Check each game
129
+ for (const game of pendingGames) {
130
+ await this.processGame(game);
131
+ }
132
+
133
+ } catch (error) {
134
+ console.error('❌ Error checking pending games:', error.message);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Check for games approaching lock time and send notifications
140
+ */
141
+ async checkUpcomingGames() {
142
+ try {
143
+ // Get ALL unresolved games from PostgreSQL (not just past lock time)
144
+ const dubsServerUrl = this.dubsServerUrl;
145
+ console.log(` 📡 Querying for upcoming games: ${dubsServerUrl}/api/games`);
146
+ const response = await axios.get(
147
+ `${dubsServerUrl}/api/games`
148
+ );
149
+
150
+ // Handle different API response formats
151
+ let allGames = [];
152
+ if (response.data.games && Array.isArray(response.data.games)) {
153
+ allGames = response.data.games;
154
+ } else if (Array.isArray(response.data)) {
155
+ allGames = response.data;
156
+ } else if (response.data.inProgress || response.data.completed || response.data.pending) {
157
+ // API returns categorized games
158
+ allGames = [
159
+ ...(response.data.inProgress || []),
160
+ ...(response.data.pending || []),
161
+ ...(response.data.completed || [])
162
+ ];
163
+ }
164
+
165
+ console.log(` API returned ${allGames.length} total game(s)`);
166
+
167
+ // Filter for automatic games that aren't resolved
168
+ const upcomingGames = allGames.filter(game =>
169
+ (game.gameMode === 4 || game.gameMode === 5) &&
170
+ !game.isResolved
171
+ );
172
+
173
+ console.log(` Found ${upcomingGames.length} unresolved automatic game(s)`);
174
+
175
+ if (upcomingGames.length === 0) {
176
+ return;
177
+ }
178
+
179
+ const now = Date.now();
180
+ const notifyWindowMs = this.notifyBeforeMinutes * 60 * 1000; // 10 minutes
181
+ const startWindowMs = 2 * 60 * 1000; // 2 minutes window for "starting now"
182
+
183
+ for (const game of upcomingGames) {
184
+ if (!game.lockTime) continue;
185
+
186
+ // Parse lock time
187
+ let lockDate;
188
+ if (game.lockTime._seconds) {
189
+ lockDate = new Date(game.lockTime._seconds * 1000);
190
+ } else if (typeof game.lockTime === 'string') {
191
+ lockDate = new Date(game.lockTime);
192
+ } else {
193
+ lockDate = new Date(game.lockTime);
194
+ }
195
+
196
+ const timeUntilLock = lockDate.getTime() - now;
197
+ const minutesUntilLock = Math.ceil(timeUntilLock / 60000);
198
+
199
+ console.log(` Game ${game.gameId}: ${minutesUntilLock}m until lock (10min notif: ${!!game.lockNotificationSent_10min}, now notif: ${!!game.lockNotificationSent_now})`);
200
+
201
+ // FIRST NOTIFICATION: 10 minutes before
202
+ if (!game.lockNotificationSent_10min && timeUntilLock > 0 && timeUntilLock <= notifyWindowMs) {
203
+ console.log(` ⏰ Game ${game.gameId} starts in ${minutesUntilLock} minutes - sending "starting soon" notification`);
204
+ await this.sendGameStartingSoonNotification(game, minutesUntilLock);
205
+ }
206
+
207
+ // SECOND NOTIFICATION: When game is starting (lock time passed)
208
+ if (!game.lockNotificationSent_now && timeUntilLock <= 0 && Math.abs(timeUntilLock) <= startWindowMs) {
209
+ console.log(` 🚨 Game ${game.gameId} is starting NOW - sending "starting now" notification`);
210
+ await this.sendGameStartingNowNotification(game);
211
+ }
212
+ }
213
+
214
+ } catch (error) {
215
+ console.error(' ⚠️ Error checking upcoming games:', error.message);
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Send "starting soon" notification (10 min before)
221
+ */
222
+ async sendGameStartingSoonNotification(game, minutesUntilStart) {
223
+ try {
224
+ // CRITICAL: Mark as notified FIRST to prevent race conditions
225
+ const dubsServerUrl = this.dubsServerUrl;
226
+
227
+ console.log(` 🔒 Marking game as notified BEFORE sending...`);
228
+ try {
229
+ await axios.post(
230
+ `${dubsServerUrl}/api/games/${game.gameId}/update-notification-flags`,
231
+ { lockNotificationSent_10min: true },
232
+ { timeout: 5000 }
233
+ );
234
+ console.log(` ✅ Flag updated successfully`);
235
+ } catch (flagError) {
236
+ console.error(' ❌ CRITICAL: Failed to update notification flag:', flagError.message);
237
+ // If we can't update the flag, DON'T send the notification to avoid infinite duplicates
238
+ throw new Error(`Cannot send notification - flag update failed: ${flagError.message}`);
239
+ }
240
+
241
+ // 1. Send to web app users via PostgreSQL + WebSocket
242
+ if (game.gameMode === 5) {
243
+ await this.sendEsportsWebAppNotifications(game, 'game_starting_soon', `${minutesUntilStart}m`);
244
+ } else {
245
+ await this.sendWebAppNotifications(game, 'game_starting_soon', `${minutesUntilStart}m`);
246
+ }
247
+
248
+ // 2. Send to Telegram (optional)
249
+ const telegramBotUrl = process.env.TELEGRAM_BOT_URL;
250
+ console.log(` 📱 TELEGRAM_BOT_URL = ${telegramBotUrl ? telegramBotUrl : 'NOT SET'}`);
251
+ if (telegramBotUrl) {
252
+ try {
253
+ console.log(` 📤 Sending to Telegram: ${telegramBotUrl}/api/notifications/game-starting`);
254
+ const response = await axios.post(
255
+ `${telegramBotUrl}/api/notifications/game-starting`,
256
+ {
257
+ gameId: game.gameId,
258
+ minutesUntilStart: minutesUntilStart
259
+ },
260
+ {
261
+ headers: { 'Content-Type': 'application/json' },
262
+ timeout: 10000
263
+ }
264
+ );
265
+ console.log(` ✅ Telegram notification sent successfully:`, response.data);
266
+ } catch (telegramError) {
267
+ console.log(' ❌ Telegram notification failed:', telegramError.message);
268
+ console.log(' Error details:', telegramError.response?.data || telegramError);
269
+ }
270
+ } else {
271
+ console.log(' ⏭️ Skipping Telegram - TELEGRAM_BOT_URL not configured');
272
+ }
273
+
274
+ console.log(` 📱 Sent "starting soon" notifications to all participants (${minutesUntilStart} min)`);
275
+
276
+ } catch (error) {
277
+ console.error(' ⚠️ Failed to send "starting soon" notification:', error.message);
278
+ throw error; // Re-throw to prevent silent failures
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Send "starting now" notification (at lock time)
284
+ */
285
+ async sendGameStartingNowNotification(game) {
286
+ try {
287
+ // CRITICAL: Mark as notified FIRST to prevent race conditions
288
+ const dubsServerUrl = this.dubsServerUrl;
289
+
290
+ console.log(` 🔒 Marking game as notified BEFORE sending...`);
291
+ try {
292
+ await axios.post(
293
+ `${dubsServerUrl}/api/games/${game.gameId}/update-notification-flags`,
294
+ { lockNotificationSent_now: true },
295
+ { timeout: 5000 }
296
+ );
297
+ console.log(` ✅ Flag updated successfully`);
298
+ } catch (flagError) {
299
+ console.error(' ❌ CRITICAL: Failed to update notification flag:', flagError.message);
300
+ // If we can't update the flag, DON'T send the notification to avoid infinite duplicates
301
+ throw new Error(`Cannot send notification - flag update failed: ${flagError.message}`);
302
+ }
303
+
304
+ // 1. Send to web app users via PostgreSQL + WebSocket
305
+ if (game.gameMode === 5) {
306
+ await this.sendEsportsWebAppNotifications(game, 'game_starting_now', 'Match is starting NOW!');
307
+ } else {
308
+ await this.sendWebAppNotifications(game, 'game_starting_now', 'Game is starting NOW!');
309
+ }
310
+
311
+ // 2. Send to Telegram group (if available)
312
+ console.log(` 📱 Telegram chat ID = ${game.telegramChatId ? game.telegramChatId : 'NOT SET'}`);
313
+ if (game.telegramChatId) {
314
+ try {
315
+ console.log(` 📤 Sending "starting now" to Telegram chat ${game.telegramChatId}`);
316
+ const telegramNotifications = require('./telegramNotifications');
317
+ await telegramNotifications.notifyGameStartingNow(game);
318
+ console.log(` ✅ Telegram "starting now" notification sent`);
319
+ } catch (telegramError) {
320
+ console.log(` ❌ Telegram "starting now" notification failed:`, telegramError.message);
321
+ }
322
+ } else {
323
+ console.log(` ⏭️ Skipping Telegram - game has no telegramChatId`);
324
+ }
325
+
326
+ console.log(` 📱 Sent "starting NOW" notifications to all participants`);
327
+
328
+ } catch (error) {
329
+ console.error(' ⚠️ Failed to send "starting now" notification:', error.message);
330
+ throw error; // Re-throw to prevent silent failures
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Process a single game - check if finished and resolve
336
+ */
337
+ async processGame(game) {
338
+ try {
339
+ console.log(`\n 📊 Checking game: ${game.gameId}`);
340
+ console.log(` Event: ${game.sportsEvent?.strEvent}`);
341
+
342
+ // First, check if game should be locked (start time has passed)
343
+ if (!game.isLocked && game.sportsEvent?.strTimestamp) {
344
+ const now = new Date();
345
+ const gameTime = new Date(game.sportsEvent.strTimestamp + 'Z');
346
+ const minutesUntilStart = Math.round((gameTime.getTime() - now.getTime()) / 60000);
347
+
348
+ console.log(` 🕐 Game time check: ${gameTime.toISOString()} (in ${minutesUntilStart} minutes)`);
349
+ console.log(` 🔓 is_locked: ${game.isLocked}`);
350
+
351
+ if (gameTime <= now) {
352
+ console.log(` 🔒 Game has started - locking game...`);
353
+ await this.lockGame(game.gameId);
354
+ console.log(` ⏭️ Skipping resolution check this iteration - will check again in next cycle after lock confirmed`);
355
+ return; // Exit early - let the lock propagate, will resolve on next check
356
+ } else {
357
+ console.log(` ⏰ Game not started yet (${minutesUntilStart}m remaining)`);
358
+ }
359
+ } else {
360
+ if (game.isLocked) {
361
+ console.log(` ✅ Game already locked, checking for resolution...`);
362
+ } else {
363
+ console.log(` ⚠️ No strTimestamp found for game`);
364
+ }
365
+ }
366
+
367
+ // Only check for resolution if game is already locked
368
+ if (!game.isLocked) {
369
+ console.log(` ⏭️ Game not locked yet - skipping resolution check`);
370
+ return;
371
+ }
372
+
373
+ // Check if game is finished — route to correct score source
374
+ let result;
375
+ if (game.gameMode === 5) {
376
+ console.log(` 🎮 Fetching PandaScore match status...`);
377
+ result = await this.checkEsportsGameResult(game.sportsEvent, game);
378
+ } else {
379
+ console.log(` 📊 Fetching live scores...`);
380
+ result = await this.checkSportsGameResult(game.sportsEvent, game);
381
+ }
382
+
383
+ if (!result) {
384
+ console.log(` ⏳ Game not found in live scores or not started yet`);
385
+ return;
386
+ }
387
+
388
+ if (!result.isFinal) {
389
+ console.log(` ⏳ Game in progress: ${result.status} (waiting for final)`);
390
+ return;
391
+ }
392
+
393
+ // Game is finished! Attempt to resolve it
394
+ console.log(` 🏆 GAME IS FINAL! Winner: ${result.winner === null ? 'REFUND (no competition)' : result.winner} (${result.homeScore}-${result.awayScore})`);
395
+ console.log(` 🔗 Attempting on-chain resolution...`);
396
+
397
+ try {
398
+ await this.resolveGame(game, result);
399
+ console.log(` ✅ Resolution completed successfully!`);
400
+ } catch (resolveError) {
401
+ // Check if it's an "already resolved" error (not actually an error)
402
+ if (resolveError.message?.includes('AlreadyResolved') ||
403
+ resolveError.message?.includes('already been resolved')) {
404
+ console.log(` ℹ️ Game already resolved on-chain - syncing database...`);
405
+ // Game is resolved on-chain but database may not be updated - sync it
406
+ try {
407
+ await this.updateGameInPostgreSQL(game.gameId, result, null);
408
+ console.log(` ✅ Database synced with on-chain state`);
409
+
410
+ // Send notifications that were missed during the failed resolution attempt
411
+ console.log(` 📢 Sending missed notifications...`);
412
+ if (game.gameMode === 5) {
413
+ await this.sendEsportsResultNotifications(game, result);
414
+ } else {
415
+ await this.sendGameResultNotifications(game, result);
416
+ }
417
+ await this.sendTelegramNotifications(game, result);
418
+ console.log(` ✅ Notifications sent`);
419
+ } catch (syncError) {
420
+ console.error(` ⚠️ Failed to sync database or send notifications:`, syncError.message);
421
+ }
422
+ return;
423
+ }
424
+ // Re-throw other errors for logging
425
+ throw resolveError;
426
+ }
427
+
428
+ } catch (error) {
429
+ // Skip logging for already resolved games (reduces noise)
430
+ if (error.message?.includes('AlreadyResolved') || error.message?.includes('already been resolved')) {
431
+ // Silent - game already resolved
432
+ return;
433
+ }
434
+
435
+ // Skip logging if lock time hasn't passed yet - this is expected
436
+ if (error.message?.includes('CannotResolveBeforeLockTime') || error.message?.includes('0x179b')) {
437
+ console.log(` ⏰ Lock time hasn't passed yet - will retry on next check`);
438
+ return;
439
+ }
440
+
441
+ console.error(` ❌ Error processing game ${game.gameId}:`, error.message);
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Lock a game when its start time has passed
447
+ */
448
+ async lockGame(gameId) {
449
+ try {
450
+ const dubsServerUrl = this.dubsServerUrl;
451
+
452
+ console.log(` 🔒 Calling lock endpoint: ${dubsServerUrl}/api/games/${gameId}/lock`);
453
+
454
+ const response = await axios.post(
455
+ `${dubsServerUrl}/api/games/${gameId}/lock`,
456
+ {
457
+ lockedAt: new Date().toISOString(),
458
+ lockedBy: 'oracle'
459
+ },
460
+ {
461
+ timeout: 10000,
462
+ headers: { 'Content-Type': 'application/json' }
463
+ }
464
+ );
465
+
466
+ console.log(` ✅ Game locked in database - Response:`, response.data);
467
+ console.log(` 📡 WebSocket event should have been emitted to all clients`);
468
+ } catch (error) {
469
+ console.error(` ⚠️ Error locking game:`, error.message);
470
+ if (error.response) {
471
+ console.error(` Status: ${error.response.status}`);
472
+ console.error(` Response:`, error.response.data);
473
+ }
474
+ // Don't throw - continue with resolution attempt
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Check if sports game is finished via live scores API
480
+ * @param {object} sportsEvent - Sports event data
481
+ * @param {object} game - Full game object with player arrays (for no-competition check)
482
+ */
483
+ async checkSportsGameResult(sportsEvent, game = null) {
484
+ try {
485
+ const { strLeague, strHomeTeam, strAwayTeam, dateEvent } = sportsEvent;
486
+
487
+ // Normalize league name for API call (TheSportsDB returns full names like "English Premier League")
488
+ const normalizedLeague = normalizeLeague(strLeague);
489
+ const liveScoresUrl = `${this.liveScoresApiUrl}/api/livescores/${normalizedLeague}`;
490
+
491
+ console.log(` 🔍 Checking live scores for:`, {
492
+ league: strLeague,
493
+ normalizedLeague,
494
+ home: strHomeTeam,
495
+ away: strAwayTeam,
496
+ date: dateEvent
497
+ });
498
+ console.log(` 🌐 Live Scores API URL: ${liveScoresUrl}`);
499
+ console.log(` 🌐 Base URL (this.liveScoresApiUrl): ${this.liveScoresApiUrl}`);
500
+
501
+ // Fetch live scores for the league
502
+ const response = await axios.get(liveScoresUrl, { timeout: 10000 });
503
+
504
+ // Handle both array format and { success, data } wrapper format
505
+ let liveScores;
506
+ if (Array.isArray(response.data)) {
507
+ liveScores = response.data;
508
+ } else if (response.data && Array.isArray(response.data.data)) {
509
+ liveScores = response.data.data;
510
+ } else {
511
+ console.log(` ⚠️ Unexpected response format:`, typeof response.data, Object.keys(response.data || {}));
512
+ liveScores = [];
513
+ }
514
+ console.log(` 📊 Received ${liveScores.length} live score(s) from API`);
515
+
516
+ // Log all available games for debugging
517
+ if (liveScores.length > 0) {
518
+ console.log(` Available games:`);
519
+ liveScores.forEach((score, idx) => {
520
+ const home = score.competitors?.find(c => c.homeAway === 'home');
521
+ const away = score.competitors?.find(c => c.homeAway === 'away');
522
+ console.log(` ${idx + 1}. ${away?.name || '?'} @ ${home?.name || '?'} (${score.date}) - ${score.status}`);
523
+ });
524
+ }
525
+
526
+ // Find our game by team names and date
527
+ // IMPORTANT: Filter for ALL matches first, then prefer "Final" status
528
+ // (ESPN API sometimes returns duplicate entries with different statuses)
529
+ const matchingGames = liveScores.filter(score => {
530
+ // Match date
531
+ if (score.date !== dateEvent) {
532
+ console.log(` Date mismatch: ${score.date} !== ${dateEvent}`);
533
+ return false;
534
+ }
535
+
536
+ // Match teams (normalize names) - check both home/away AND swapped positions
537
+ // UFC/MMA sometimes swaps home/away between scheduled and final entries
538
+ const homeExpected = this.normalizeTeamName(strHomeTeam);
539
+ const awayExpected = this.normalizeTeamName(strAwayTeam);
540
+
541
+ const competitors = score.competitors || [];
542
+ const homeCompetitor = competitors.find(c => c.homeAway === 'home');
543
+ const awayCompetitor = competitors.find(c => c.homeAway === 'away');
544
+
545
+ if (!homeCompetitor || !awayCompetitor) return false;
546
+
547
+ const homeActual = this.normalizeTeamName(homeCompetitor.name);
548
+ const awayActual = this.normalizeTeamName(awayCompetitor.name);
549
+
550
+ // Check standard match (home=home, away=away)
551
+ const standardMatch = homeActual === homeExpected && awayActual === awayExpected;
552
+
553
+ // Check swapped match (home=away, away=home) - common in UFC
554
+ const swappedMatch = homeActual === awayExpected && awayActual === homeExpected;
555
+
556
+ if (!standardMatch && !swappedMatch) {
557
+ console.log(` Home team mismatch: "${homeActual}" !== "${homeExpected}"`);
558
+ console.log(` Away team mismatch: "${awayActual}" !== "${awayExpected}"`);
559
+ }
560
+
561
+ return standardMatch || swappedMatch;
562
+ });
563
+
564
+ if (matchingGames.length === 0) {
565
+ console.log(` ❌ Game not found in live scores`);
566
+ return null; // Game not found in live scores yet
567
+ }
568
+
569
+ // Prefer "Final" status over other statuses (handles duplicate entries)
570
+ const finalStatuses = ['Final', 'FT', 'Full Time', 'AET', 'FT-Pens'];
571
+ let gameResult = matchingGames.find(g =>
572
+ finalStatuses.some(s => g.status?.toLowerCase() === s.toLowerCase())
573
+ );
574
+
575
+ // If no Final entry, use the first match (in-progress or scheduled)
576
+ if (!gameResult) {
577
+ gameResult = matchingGames[0];
578
+ }
579
+
580
+ console.log(` ✅ Found ${matchingGames.length} matching game(s), using: ${gameResult.status}`);
581
+
582
+ // Check if game is final (reuse finalStatuses from above)
583
+ const isFinal = finalStatuses.some(s =>
584
+ gameResult.status?.toLowerCase() === s.toLowerCase()
585
+ );
586
+
587
+ if (!isFinal) {
588
+ console.log(` ⏳ Game in progress: ${gameResult.status}`);
589
+ return { isFinal: false, status: gameResult.status };
590
+ }
591
+
592
+ // Determine winner
593
+ const homeTeam = gameResult.competitors.find(c => c.homeAway === 'home');
594
+ const awayTeam = gameResult.competitors.find(c => c.homeAway === 'away');
595
+
596
+ if (!homeTeam || !awayTeam) {
597
+ console.error(` ❌ Missing team data in game result`);
598
+ return null;
599
+ }
600
+
601
+ // 🔐 CRITICAL: Check if there's competition (multiple sides betting)
602
+ // This MUST match the smart contract's edge case handling!
603
+ const homePlayerCount = game?.homeTeamPlayers?.length || 0;
604
+ const awayPlayerCount = game?.awayTeamPlayers?.length || 0;
605
+ const drawPlayerCount = game?.drawTeamPlayers?.length || 0;
606
+ const totalPlayerCount = homePlayerCount + awayPlayerCount + drawPlayerCount;
607
+
608
+ console.log(` 👥 Player distribution: Home=${homePlayerCount}, Away=${awayPlayerCount}, Draw=${drawPlayerCount}`);
609
+
610
+ let winner;
611
+
612
+ // Determine winner by score first
613
+ let scoreBasedWinner;
614
+ if (homeTeam.score > awayTeam.score) {
615
+ scoreBasedWinner = 'home';
616
+ } else if (awayTeam.score > homeTeam.score) {
617
+ scoreBasedWinner = 'away';
618
+ } else {
619
+ scoreBasedWinner = 'draw'; // Tied score
620
+ }
621
+
622
+ // Check for competition: Need at least one winner AND one loser
623
+ // - If home wins, need at least 1 away or draw bettor
624
+ // - If away wins, need at least 1 home or draw bettor
625
+ // - If draw, need at least 1 home or away bettor
626
+ const winnerCount = scoreBasedWinner === 'home' ? homePlayerCount :
627
+ scoreBasedWinner === 'away' ? awayPlayerCount : drawPlayerCount;
628
+ const loserCount = totalPlayerCount - winnerCount;
629
+
630
+ if (winnerCount === 0 || loserCount === 0) {
631
+ // No competition - either no winners or no losers
632
+ winner = null; // Refund everyone
633
+ console.log(` ⚠️ NO COMPETITION: ${winnerCount === 0 ? 'No bettors on winning side' : 'No bettors on losing side'} - resolving as REFUND`);
634
+ console.log(` 💰 All ${totalPlayerCount} player(s) will get refunded (minus 6% fees)`);
635
+ } else {
636
+ // Normal game with competition
637
+ winner = scoreBasedWinner;
638
+ }
639
+
640
+ console.log(` 🏆 Game final! Winner: ${winner === null ? 'REFUND (no competition)' : winner} (${awayTeam.score}-${homeTeam.score})`);
641
+
642
+ return {
643
+ isFinal: true,
644
+ status: 'Final',
645
+ winner,
646
+ homeScore: homeTeam.score,
647
+ awayScore: awayTeam.score
648
+ };
649
+
650
+ } catch (error) {
651
+ console.error(' ❌ Error checking sports game result:', error.message);
652
+ if (error.response) {
653
+ console.error(` API Error: ${error.response.status} - ${error.response.statusText}`);
654
+ }
655
+ return null;
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Check if an esports match is finished via PandaScore API
661
+ * @param {object} sportsEvent - Esports event data (stored at game creation from PandaScore)
662
+ * @param {object} game - Full game object with player arrays (for no-competition check)
663
+ */
664
+ async checkEsportsGameResult(sportsEvent, game = null) {
665
+ try {
666
+ const pandascoreMatchId = sportsEvent.pandascoreMatchId;
667
+ if (!pandascoreMatchId) {
668
+ console.error(' ❌ No pandascoreMatchId found in sportsEvent');
669
+ return null;
670
+ }
671
+
672
+ // Fetch match status from PandaScore via our proxy
673
+ const matchUrl = `${this.dubsServerUrl}/api/esports/matches/${pandascoreMatchId}`;
674
+ console.log(` 🎮 Fetching PandaScore match: ${matchUrl}`);
675
+
676
+ const response = await axios.get(matchUrl, { timeout: 10000 });
677
+ const match = response.data?.data;
678
+
679
+ if (!match) {
680
+ console.log(' ❌ Match not found in PandaScore response');
681
+ return null;
682
+ }
683
+
684
+ console.log(` 🎮 Match status: ${match.status}, winner_id: ${match.winner_id || 'none'}`);
685
+
686
+ // PandaScore match statuses: "not_started", "running", "finished", "canceled", "postponed"
687
+ if (match.status === 'canceled' || match.status === 'postponed') {
688
+ // Forfeit wins: PandaScore marks as "canceled" with forfeit=true AND a winner_id
689
+ if (match.forfeit && match.winner_id) {
690
+ console.log(` 🏳️ Match is a FORFEIT — winner_id: ${match.winner_id}, proceeding to determine winner`);
691
+ // Fall through to winner determination logic below
692
+ } else {
693
+ console.log(` ⚠️ Match ${match.status} - resolving as REFUND`);
694
+ return {
695
+ isFinal: true,
696
+ status: match.status,
697
+ winner: null, // Refund all players
698
+ homeScore: 0,
699
+ awayScore: 0
700
+ };
701
+ }
702
+ }
703
+
704
+ if (match.status !== 'finished' && !(match.forfeit && match.winner_id)) {
705
+ console.log(` ⏳ Match not finished yet: ${match.status}`);
706
+ return { isFinal: false, status: match.status };
707
+ }
708
+
709
+ // Match is finished — determine winner
710
+ const opponents = sportsEvent.opponents || match.opponents || [];
711
+ if (opponents.length < 2) {
712
+ console.error(' ❌ Match has fewer than 2 opponents');
713
+ return null;
714
+ }
715
+
716
+ // Calculate map scores from match.results
717
+ let homeScore = 0;
718
+ let awayScore = 0;
719
+ if (match.results && Array.isArray(match.results)) {
720
+ const team0Result = match.results.find(r => r.team_id === opponents[0].opponent?.id);
721
+ const team1Result = match.results.find(r => r.team_id === opponents[1].opponent?.id);
722
+ homeScore = team0Result?.score || 0;
723
+ awayScore = team1Result?.score || 0;
724
+ }
725
+
726
+ // Determine winner: PandaScore gives us winner_id directly
727
+ const winnerId = match.winner_id;
728
+ let scoreBasedWinner;
729
+
730
+ if (winnerId === opponents[0].opponent?.id) {
731
+ scoreBasedWinner = 'home'; // opponent[0] = home
732
+ } else if (winnerId === opponents[1].opponent?.id) {
733
+ scoreBasedWinner = 'away'; // opponent[1] = away
734
+ } else {
735
+ console.error(` ❌ winner_id ${winnerId} doesn't match either opponent`);
736
+ if (homeScore > awayScore) scoreBasedWinner = 'home';
737
+ else if (awayScore > homeScore) scoreBasedWinner = 'away';
738
+ else {
739
+ console.error(' ❌ Cannot determine winner - scores tied and winner_id mismatch');
740
+ return null;
741
+ }
742
+ }
743
+
744
+ console.log(` 🏆 Winner: ${scoreBasedWinner} (${opponents[scoreBasedWinner === 'home' ? 0 : 1].opponent?.name})`);
745
+
746
+ // Check for competition (same logic as sports)
747
+ const homePlayerCount = game?.homeTeamPlayers?.length || 0;
748
+ const awayPlayerCount = game?.awayTeamPlayers?.length || 0;
749
+ const totalPlayerCount = homePlayerCount + awayPlayerCount;
750
+
751
+ console.log(` 👥 Player distribution: Home=${homePlayerCount}, Away=${awayPlayerCount}`);
752
+
753
+ let winner;
754
+ const winnerCount = scoreBasedWinner === 'home' ? homePlayerCount : awayPlayerCount;
755
+ const loserCount = totalPlayerCount - winnerCount;
756
+
757
+ if (winnerCount === 0 || loserCount === 0) {
758
+ winner = null;
759
+ console.log(` ⚠️ NO COMPETITION: ${winnerCount === 0 ? 'No bettors on winning side' : 'No bettors on losing side'} - resolving as REFUND`);
760
+ } else {
761
+ winner = scoreBasedWinner;
762
+ }
763
+
764
+ return {
765
+ isFinal: true,
766
+ status: 'Final',
767
+ winner,
768
+ homeScore,
769
+ awayScore
770
+ };
771
+
772
+ } catch (error) {
773
+ console.error(' ❌ Error checking esports game result:', error.message);
774
+ if (error.response) {
775
+ console.error(` API Error: ${error.response.status} - ${error.response.statusText}`);
776
+ }
777
+ return null;
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Normalize team names for matching
783
+ * Handles both American sports (prefixes) and soccer (suffixes)
784
+ */
785
+ normalizeTeamName(name) {
786
+ return name
787
+ .toLowerCase()
788
+ .trim()
789
+ // American sports - remove city prefixes
790
+ .replace(/^(los angeles|la)\s+/i, '')
791
+ .replace(/^(new york|ny)\s+/i, '')
792
+ .replace(/^(san francisco|sf)\s+/i, '')
793
+ .replace(/^(golden state)\s+/i, '')
794
+ // Soccer/EPL - remove common PREFIXES (e.g., "AFC Bournemouth" → "bournemouth")
795
+ .replace(/^(afc|fc)\s+/i, '')
796
+ // Soccer/EPL - remove common suffixes
797
+ .replace(/\s+(fc|afc|sc|cf)$/i, '')
798
+ .replace(/\s+(united|city|town|athletic|hotspur|wanderers|rovers|albion)$/i, '')
799
+ // Remove "& Hove" or "and Hove" from "Brighton & Hove Albion" type names
800
+ .replace(/\s*(and|&)\s*hove\s*/i, ' ')
801
+ // Remove ampersands and normalize
802
+ .replace(/\s*&\s*/g, ' ')
803
+ // Normalize whitespace
804
+ .replace(/\s+/g, ' ')
805
+ .trim();
806
+ }
807
+
808
+ /**
809
+ * Resolve game on-chain by calling the smart contract
810
+ */
811
+ async resolveGame(game, result) {
812
+ try {
813
+ const gameId = game.gameId;
814
+ console.log(`\n 🔗 Resolving game on-chain: ${gameId}`);
815
+ console.log(` - Game ID: ${gameId}`);
816
+ console.log(` - Winner: ${result.winner}`);
817
+ console.log(` - Score: ${result.homeScore}-${result.awayScore}`);
818
+
819
+ // 🎁 Look up game creator's referrer for on-chain commission payout
820
+ const referrerWallet = await this.getGameCreatorReferrer(gameId);
821
+ if (referrerWallet) {
822
+ console.log(` 🎁 Referrer wallet: ${referrerWallet} (will receive 1% commission on-chain)`);
823
+ } else {
824
+ console.log(` ℹ️ No referrer for game creator - platform keeps full 5%`);
825
+ }
826
+
827
+ // Build and send resolve_automatic_game transaction
828
+ const signature = await this.buildAndSendResolveTransaction(gameId, result, referrerWallet);
829
+ console.log(` ✅ On-chain resolution signature: ${signature}`);
830
+
831
+ // Update PostgreSQL database after successful on-chain resolution
832
+ // Pass signature so winners get claim_signature updated for transaction history matching
833
+ await this.updateGameInPostgreSQL(gameId, result, signature);
834
+
835
+ // 🎁 Record referral commission in database for tracking/display
836
+ if (referrerWallet) {
837
+ await this.recordReferralCommission(game, referrerWallet, signature);
838
+ }
839
+
840
+ // 🎟️ Record promo code outcome (if any sponsored players in this game)
841
+ // Outcome: 'won', 'lost', or 'refunded' (for no competition/cancelled)
842
+ try {
843
+ let promoOutcome;
844
+ if (result.winner === null) {
845
+ // No competition or cancelled - refund
846
+ promoOutcome = 'refunded';
847
+ } else {
848
+ // Game had a winner (home, away, or draw) - check if sponsored player won
849
+ // For now, just mark as 'lost' for simplicity - the real check would be complex
850
+ promoOutcome = 'lost'; // Default, frontend can show actual outcome
851
+ }
852
+ await promoService.recordGameOutcome(gameId, promoOutcome);
853
+ } catch (promoErr) {
854
+ console.log(` ⚠️ No promo code for this game (expected if not sponsored)`);
855
+ }
856
+
857
+ // Send web app notifications to participants (game won/lost)
858
+ if (game.gameMode === 5) {
859
+ await this.sendEsportsResultNotifications(game, result);
860
+ } else {
861
+ await this.sendGameResultNotifications(game, result);
862
+ }
863
+
864
+ // Send Telegram notifications to participants
865
+ await this.sendTelegramNotifications(game, result);
866
+
867
+ console.log(` ✅ Game resolved successfully!`);
868
+
869
+ } catch (error) {
870
+ console.error(` ❌ Error resolving game:`, error.message);
871
+ throw error;
872
+ }
873
+ }
874
+
875
+ /**
876
+ * Look up the referrer wallet of the game creator
877
+ * Returns null if game creator has no referrer
878
+ */
879
+ async getGameCreatorReferrer(gameId) {
880
+ const logPrefix = `[REFERRAL:${gameId.slice(-8)}]`;
881
+ try {
882
+ console.log(`${logPrefix} 🔍 Looking up game creator's referrer...`);
883
+ console.log(`${logPrefix} URL: ${this.dubsServerUrl}/api/games/${gameId}/creator-referrer`);
884
+
885
+ const response = await axios.get(
886
+ `${this.dubsServerUrl}/api/games/${gameId}/creator-referrer`,
887
+ { timeout: 5000 }
888
+ );
889
+
890
+ console.log(`${logPrefix} Response status: ${response.status}`);
891
+ console.log(`${logPrefix} Response data: ${JSON.stringify(response.data)}`);
892
+
893
+ if (response.data.success && response.data.referrerWallet) {
894
+ console.log(`${logPrefix} ✅ Found referrer: ${response.data.referrerWallet}`);
895
+ console.log(`${logPrefix} Creator: ${response.data.creatorUsername || 'unknown'}`);
896
+ console.log(`${logPrefix} Referrer: ${response.data.referrerUsername || 'unknown'}`);
897
+ return response.data.referrerWallet;
898
+ }
899
+
900
+ console.log(`${logPrefix} ℹ️ No referrer found - creator has no referral_code or referrer not in DB`);
901
+ console.log(`${logPrefix} success: ${response.data.success}, referrerWallet: ${response.data.referrerWallet || 'null'}`);
902
+ return null;
903
+ } catch (error) {
904
+ console.error(`${logPrefix} ❌ ERROR fetching referrer info:`);
905
+ console.error(`${logPrefix} Message: ${error.message}`);
906
+ if (error.response) {
907
+ console.error(`${logPrefix} Status: ${error.response.status}`);
908
+ console.error(`${logPrefix} Data: ${JSON.stringify(error.response.data)}`);
909
+ }
910
+ return null;
911
+ }
912
+ }
913
+
914
+ /**
915
+ * Record referral commission in database for tracking
916
+ * The actual SOL transfer happens on-chain during resolution
917
+ * @param {Object} game - Game data
918
+ * @param {string} referrerWallet - Wallet that receives commission
919
+ * @param {string} txSignature - Transaction signature for the payout
920
+ */
921
+ async recordReferralCommission(game, referrerWallet, txSignature) {
922
+ const logPrefix = `[REFERRAL:${game.gameId?.slice(-8) || 'unknown'}]`;
923
+ try {
924
+ console.log(`${logPrefix} 💾 Recording referral commission to database...`);
925
+ console.log(`${logPrefix} Referrer wallet: ${referrerWallet}`);
926
+ console.log(`${logPrefix} TX signature: ${txSignature}`);
927
+
928
+ // Calculate pot size: buyIn * playerCount (including draw players for EPL)
929
+ const buyInSOL = parseFloat(game.buyIn) || 0;
930
+ const homePlayers = Array.isArray(game.homeTeamPlayers) ? game.homeTeamPlayers.length : 0;
931
+ const awayPlayers = Array.isArray(game.awayTeamPlayers) ? game.awayTeamPlayers.length : 0;
932
+ const drawPlayers = Array.isArray(game.drawTeamPlayers) ? game.drawTeamPlayers.length : 0;
933
+ const playerCount = homePlayers + awayPlayers + drawPlayers;
934
+ const potSizeSOL = buyInSOL * playerCount;
935
+ const potSizeLamports = Math.floor(potSizeSOL * 1_000_000_000);
936
+ const commissionLamports = Math.floor(potSizeLamports * 0.01); // 1%
937
+
938
+ console.log(`${logPrefix} Game data: buyIn=${buyInSOL}, home=${homePlayers}, away=${awayPlayers}, draw=${drawPlayers}`);
939
+ console.log(`${logPrefix} Pot: ${potSizeSOL} SOL (${playerCount} players × ${buyInSOL} SOL)`);
940
+ console.log(`${logPrefix} Commission: ${commissionLamports / 1_000_000_000} SOL (${commissionLamports} lamports)`);
941
+
942
+ if (commissionLamports <= 0) {
943
+ console.log(`${logPrefix} ⚠️ SKIPPING - commission is 0 (pot too small or no players)`);
944
+ return;
945
+ }
946
+
947
+ const requestBody = {
948
+ gameId: game.gameId,
949
+ referrerWallet,
950
+ commissionLamports,
951
+ paidOnChain: true,
952
+ txSignature
953
+ };
954
+
955
+ console.log(`${logPrefix} POST ${this.dubsServerUrl}/api/referral-earnings/record`);
956
+ console.log(`${logPrefix} Body: ${JSON.stringify(requestBody)}`);
957
+
958
+ const response = await axios.post(
959
+ `${this.dubsServerUrl}/api/referral-earnings/record`,
960
+ requestBody,
961
+ { timeout: 5000 }
962
+ );
963
+
964
+ console.log(`${logPrefix} ✅ Database record created/updated successfully`);
965
+ console.log(`${logPrefix} Response: ${JSON.stringify(response.data)}`);
966
+ } catch (error) {
967
+ console.error(`${logPrefix} ❌ FAILED to record referral commission:`);
968
+ console.error(`${logPrefix} Error: ${error.message}`);
969
+ if (error.response) {
970
+ console.error(`${logPrefix} Status: ${error.response.status}`);
971
+ console.error(`${logPrefix} Data: ${JSON.stringify(error.response.data)}`);
972
+ }
973
+ // Non-critical - on-chain payout already happened, but DB tracking failed
974
+ }
975
+ }
976
+
977
+ /**
978
+ * Update game status in PostgreSQL after resolution
979
+ * @param {string} gameId - Game ID
980
+ * @param {object} result - Game result (winner, scores)
981
+ * @param {string} signature - On-chain transaction signature (for claim_signature tracking)
982
+ */
983
+ async updateGameInPostgreSQL(gameId, result, signature = null) {
984
+ try {
985
+ // Determine the dubs-server API URL
986
+ // Priority: DUBS_SERVER_URL > Constructed from PORT > Production default
987
+ let dubsServerUrl;
988
+
989
+ if (process.env.DUBS_SERVER_URL) {
990
+ dubsServerUrl = process.env.DUBS_SERVER_URL;
991
+ } else if (process.env.PORT) {
992
+ // Running on Heroku or locally - construct URL
993
+ const port = process.env.PORT;
994
+ dubsServerUrl = process.env.NODE_ENV === 'production'
995
+ ? `https://dubs-server-production.herokuapp.com` // Adjust this to your actual Heroku app name
996
+ : `http://localhost:${port}`;
997
+ } else {
998
+ // Final fallback
999
+ dubsServerUrl = 'http://localhost:3001';
1000
+ }
1001
+
1002
+ console.log(` 📡 Updating PostgreSQL at ${dubsServerUrl}...`);
1003
+
1004
+ // Update via dubs-server (it has PostgreSQL access)
1005
+ // Pass resolveSignature so winners get claim_signature updated for transaction history matching
1006
+ await axios.post(
1007
+ `${dubsServerUrl}/api/games/${gameId}/resolve`,
1008
+ {
1009
+ winner: result.winner,
1010
+ homeScore: result.homeScore,
1011
+ awayScore: result.awayScore,
1012
+ resolvedAt: new Date().toISOString(),
1013
+ resolvedBy: 'oracle',
1014
+ resolveSignature: signature // 🎯 Critical for transaction history matching
1015
+ },
1016
+ {
1017
+ timeout: 10000,
1018
+ headers: { 'Content-Type': 'application/json' }
1019
+ }
1020
+ );
1021
+ console.log(` ✅ PostgreSQL updated (including claim_signature for winners)`);
1022
+ } catch (error) {
1023
+ console.error(' ⚠️ Error updating PostgreSQL:', error.message);
1024
+ if (error.response) {
1025
+ console.error(` Status: ${error.response.status}`);
1026
+ console.error(` Data:`, error.response.data);
1027
+ }
1028
+ // Don't throw - on-chain resolution is what matters
1029
+ }
1030
+ }
1031
+
1032
+ /**
1033
+ * Build and send resolve transaction to Solana
1034
+ * @param {string} gameId - Game ID
1035
+ * @param {object} result - Game result (winner, scores)
1036
+ * @param {string|null} referrerWallet - Optional referrer wallet to receive 1% commission
1037
+ */
1038
+ async buildAndSendResolveTransaction(gameId, result, referrerWallet = null) {
1039
+ const { PublicKey, Transaction, TransactionInstruction, SystemProgram } = require('@solana/web3.js');
1040
+
1041
+ // Get game PDA (same logic as server.js)
1042
+ const crypto = require('crypto');
1043
+ const programId = new PublicKey(this.programId);
1044
+
1045
+ let gameIdNum;
1046
+ if (typeof gameId === 'string' && gameId.includes('-')) {
1047
+ const hash = crypto.createHash('sha256').update(gameId).digest();
1048
+ gameIdNum = hash.readBigUInt64LE(0);
1049
+ } else {
1050
+ gameIdNum = BigInt(gameId);
1051
+ }
1052
+
1053
+ const gameIdBuf = Buffer.alloc(8);
1054
+ gameIdBuf.writeBigUInt64LE(gameIdNum);
1055
+
1056
+ const [gamePDA] = PublicKey.findProgramAddressSync(
1057
+ [Buffer.from("game"), gameIdBuf],
1058
+ programId
1059
+ );
1060
+
1061
+ // Encode winning team: Some(Home) = [1, 0], Some(Away) = [1, 1], Some(Draw) = [1, 2], None (refund) = [0]
1062
+ let winningTeamBytes;
1063
+ if (result.winner === null) {
1064
+ winningTeamBytes = Buffer.from([0]); // None - refund all (no competition)
1065
+ } else if (result.winner === 'home') {
1066
+ winningTeamBytes = Buffer.from([1, 0]); // Some(Home)
1067
+ } else if (result.winner === 'away') {
1068
+ winningTeamBytes = Buffer.from([1, 1]); // Some(Away)
1069
+ } else if (result.winner === 'draw') {
1070
+ winningTeamBytes = Buffer.from([1, 2]); // Some(Draw) - draw bettors win
1071
+ } else {
1072
+ // Fallback safety - should never reach here
1073
+ console.error(` ⚠️ Unexpected winner value: ${result.winner}, defaulting to refund`);
1074
+ winningTeamBytes = Buffer.from([0]); // None - refund all
1075
+ }
1076
+
1077
+ // Discriminator for resolve_automatic_game
1078
+ const RESOLVE_AUTO = Buffer.from([245, 33, 115, 150, 82, 150, 28, 193]);
1079
+
1080
+ // Build instruction data
1081
+ const data = Buffer.concat([
1082
+ RESOLVE_AUTO,
1083
+ gameIdBuf,
1084
+ winningTeamBytes
1085
+ ]);
1086
+
1087
+ // ═══════════════════════════════════════════════════════════════════════
1088
+ // 💸 FEE DISTRIBUTION ACCOUNTS
1089
+ // ═══════════════════════════════════════════════════════════════════════
1090
+ // remaining_accounts[0] = Operator wallet (receives 4% or 5%)
1091
+ // remaining_accounts[1] = Referrer wallet (optional, receives 1% if present)
1092
+ // Oracle is the signer and receives 1% directly
1093
+ // ═══════════════════════════════════════════════════════════════════════
1094
+
1095
+ const keys = [
1096
+ { pubkey: gamePDA, isSigner: false, isWritable: true },
1097
+ { pubkey: this.oracleKeypair.publicKey, isSigner: true, isWritable: true }, // Writable! Receives 1% fee
1098
+ // remaining_accounts[0]: Operator wallet
1099
+ { pubkey: OPERATOR_WALLET, isSigner: false, isWritable: true },
1100
+ ];
1101
+
1102
+ // 🎁 If referrer exists, add as remaining_accounts[1] to receive 1% commission
1103
+ if (referrerWallet) {
1104
+ try {
1105
+ const referrerPubkey = new PublicKey(referrerWallet);
1106
+ keys.push({ pubkey: referrerPubkey, isSigner: false, isWritable: true });
1107
+ console.log(` 🎁 Added referrer to transaction: ${referrerWallet}`);
1108
+ } catch (e) {
1109
+ console.log(` ⚠️ Invalid referrer wallet address: ${referrerWallet}`);
1110
+ }
1111
+ }
1112
+
1113
+ const ix = new TransactionInstruction({
1114
+ keys,
1115
+ programId: programId,
1116
+ data,
1117
+ });
1118
+
1119
+ const tx = new Transaction().add(ix);
1120
+ tx.recentBlockhash = (await this.connection.getLatestBlockhash()).blockhash;
1121
+ tx.feePayer = this.oracleKeypair.publicKey;
1122
+
1123
+ // Sign with oracle keypair
1124
+ tx.sign(this.oracleKeypair);
1125
+
1126
+ // Send transaction
1127
+ const signature = await this.connection.sendRawTransaction(tx.serialize());
1128
+ await this.confirmTransactionPolling(signature);
1129
+
1130
+ return signature;
1131
+ }
1132
+
1133
+ /**
1134
+ * Poll for transaction confirmation (avoids WebSocket subscription which some RPC providers don't support)
1135
+ * @param {string} signature - Transaction signature
1136
+ * @param {number} timeout - Timeout in milliseconds (default 60000)
1137
+ */
1138
+ async confirmTransactionPolling(signature, timeout = 60000) {
1139
+ const start = Date.now();
1140
+
1141
+ while (Date.now() - start < timeout) {
1142
+ const statuses = await this.connection.getSignatureStatuses([signature]);
1143
+ const status = statuses?.value?.[0];
1144
+
1145
+ if (status?.err) {
1146
+ throw new Error(`Transaction failed: ${JSON.stringify(status.err)}`);
1147
+ }
1148
+
1149
+ if (status?.confirmationStatus === 'confirmed' || status?.confirmationStatus === 'finalized') {
1150
+ console.log(` ✅ Transaction confirmed: ${status.confirmationStatus}`);
1151
+ return status;
1152
+ }
1153
+
1154
+ await new Promise(resolve => setTimeout(resolve, 1000));
1155
+ }
1156
+
1157
+ throw new Error('Transaction confirmation timeout');
1158
+ }
1159
+
1160
+ /**
1161
+ * Send Telegram notifications via Telegram bot
1162
+ */
1163
+ async sendTelegramNotifications(game, result) {
1164
+ try {
1165
+ const telegramBotUrl = process.env.TELEGRAM_BOT_URL;
1166
+
1167
+ if (!telegramBotUrl) {
1168
+ console.log(' ℹ️ TELEGRAM_BOT_URL not set - skipping Telegram notifications');
1169
+ return;
1170
+ }
1171
+
1172
+ // Send via Telegram bot
1173
+ const response = await axios.post(
1174
+ `${telegramBotUrl}/api/notifications/game-finished`,
1175
+ {
1176
+ gameId: game.gameId,
1177
+ finalScore: {
1178
+ winner: result.winner,
1179
+ homeScore: result.homeScore,
1180
+ awayScore: result.awayScore
1181
+ }
1182
+ },
1183
+ {
1184
+ headers: { 'Content-Type': 'application/json' },
1185
+ timeout: 10000
1186
+ }
1187
+ );
1188
+
1189
+ if (response.data.success) {
1190
+ console.log(` 📱 Sent Telegram notifications to all participants`);
1191
+ } else {
1192
+ console.log(` ⚠️ Failed to send Telegram notifications:`, response.data.error);
1193
+ }
1194
+
1195
+ } catch (error) {
1196
+ console.error(' ⚠️ Failed to send Telegram notifications:', error.message);
1197
+ // Don't throw - notifications are non-critical
1198
+ }
1199
+ }
1200
+
1201
+ /**
1202
+ * Send game result notifications (won/lost) to all participants
1203
+ */
1204
+ async sendGameResultNotifications(game, result) {
1205
+ try {
1206
+ console.log(` 📧 Sending game result notifications for ${game.gameId}`);
1207
+ console.log(` Game data:`, {
1208
+ hasEvent: !!game.sportsEvent,
1209
+ homeTeam: game.sportsEvent?.strHomeTeam,
1210
+ awayTeam: game.sportsEvent?.strAwayTeam,
1211
+ buyIn: game.buyIn
1212
+ });
1213
+
1214
+ const gameInvite = {
1215
+ gameId: game.gameId,
1216
+ gameAddress: game.gameAddress || '',
1217
+ title: `${game.sportsEvent?.strAwayTeam || 'Away'} @ ${game.sportsEvent?.strHomeTeam || 'Home'}`,
1218
+ imageUrl: game.sportsEvent?.strThumb || game.sportsEvent?.strSquare || '',
1219
+ matchupImageUrl: game.matchupImageUrl, // Pre-generated S3 matchup image
1220
+ buyIn: game.buyIn || 0,
1221
+ league: normalizeLeague(game.sportsEvent?.strLeague),
1222
+ homeTeam: game.sportsEvent?.strHomeTeam,
1223
+ awayTeam: game.sportsEvent?.strAwayTeam,
1224
+ homeTeamBadge: game.sportsEvent?.strHomeTeamBadge,
1225
+ awayTeamBadge: game.sportsEvent?.strAwayTeamBadge,
1226
+ };
1227
+
1228
+ const finalScore = {
1229
+ winner: result.winner,
1230
+ homeScore: result.homeScore,
1231
+ awayScore: result.awayScore,
1232
+ };
1233
+
1234
+ console.log(` 📧 Built gameInvite:`, {
1235
+ title: gameInvite.title,
1236
+ hasImage: !!gameInvite.imageUrl,
1237
+ buyIn: gameInvite.buyIn
1238
+ });
1239
+
1240
+ // Get all unique participant wallet addresses with their team choices
1241
+ const participants = [];
1242
+ if (game.homeTeamPlayers && Array.isArray(game.homeTeamPlayers)) {
1243
+ game.homeTeamPlayers.forEach(wallet => {
1244
+ if (typeof wallet === 'string') {
1245
+ participants.push({ wallet, teamChoice: 'home' });
1246
+ }
1247
+ });
1248
+ }
1249
+ if (game.awayTeamPlayers && Array.isArray(game.awayTeamPlayers)) {
1250
+ game.awayTeamPlayers.forEach(wallet => {
1251
+ if (typeof wallet === 'string') {
1252
+ participants.push({ wallet, teamChoice: 'away' });
1253
+ }
1254
+ });
1255
+ }
1256
+ if (game.drawTeamPlayers && Array.isArray(game.drawTeamPlayers)) {
1257
+ game.drawTeamPlayers.forEach(wallet => {
1258
+ if (typeof wallet === 'string') {
1259
+ participants.push({ wallet, teamChoice: 'draw' });
1260
+ }
1261
+ });
1262
+ }
1263
+
1264
+ if (participants.length === 0) {
1265
+ console.log(' ℹ️ No participants found for result notifications');
1266
+ return;
1267
+ }
1268
+
1269
+ const dubsServerUrl = this.dubsServerUrl;
1270
+
1271
+ for (const participant of participants) {
1272
+ try {
1273
+ const userWon = participant.teamChoice === result.winner;
1274
+ const notificationType = userWon ? 'game_won' : 'game_lost';
1275
+ const message = userWon
1276
+ ? `${result.homeScore}-${result.awayScore}`
1277
+ : `${result.homeScore}-${result.awayScore}`;
1278
+
1279
+ await axios.post(
1280
+ `${dubsServerUrl}/api/games/notify-participant`,
1281
+ {
1282
+ walletAddress: participant.wallet,
1283
+ notificationType,
1284
+ message,
1285
+ gameInvite,
1286
+ finalScore,
1287
+ },
1288
+ {
1289
+ headers: { 'Content-Type': 'application/json' },
1290
+ timeout: 5000
1291
+ }
1292
+ );
1293
+ } catch (err) {
1294
+ console.log(` ⚠️ Failed to notify ${participant.wallet?.slice(0, 8)}:`, err.message);
1295
+ }
1296
+ }
1297
+
1298
+ console.log(` ✅ Sent game result notifications to ${participants.length} participant(s)`);
1299
+
1300
+ } catch (error) {
1301
+ console.error(' ⚠️ Failed to send game result notifications:', error.message);
1302
+ }
1303
+ }
1304
+
1305
+ /**
1306
+ * Send web app notifications to game participants
1307
+ * Stores in PostgreSQL chat_notifications table and emits via WebSocket
1308
+ */
1309
+ async sendWebAppNotifications(game, notificationType, message) {
1310
+ try {
1311
+ // Build game invite metadata for notification
1312
+ const gameInvite = {
1313
+ gameId: game.gameId,
1314
+ gameAddress: game.gameAddress || '',
1315
+ title: `${game.sportsEvent?.strAwayTeam || 'Away'} @ ${game.sportsEvent?.strHomeTeam || 'Home'}`,
1316
+ imageUrl: game.sportsEvent?.strThumb || game.sportsEvent?.strSquare || '',
1317
+ matchupImageUrl: game.matchupImageUrl, // Pre-generated S3 matchup image
1318
+ buyIn: game.buyIn || 0,
1319
+ totalPool: game.totalPool || 0, // Total pot for pari-mutuel display
1320
+ league: normalizeLeague(game.sportsEvent?.strLeague),
1321
+ homeTeam: game.sportsEvent?.strHomeTeam,
1322
+ awayTeam: game.sportsEvent?.strAwayTeam,
1323
+ homeTeamBadge: game.sportsEvent?.strHomeTeamBadge,
1324
+ awayTeamBadge: game.sportsEvent?.strAwayTeamBadge,
1325
+ strTimestamp: game.sportsEvent?.strTimestamp,
1326
+ };
1327
+
1328
+ console.log(` 📧 Built gameInvite for ${notificationType}:`, {
1329
+ title: gameInvite.title,
1330
+ hasImage: !!gameInvite.imageUrl,
1331
+ buyIn: gameInvite.buyIn
1332
+ });
1333
+
1334
+ // Get all unique participant wallet addresses
1335
+ const participantWallets = new Set();
1336
+ if (game.homeTeamPlayers && Array.isArray(game.homeTeamPlayers)) {
1337
+ game.homeTeamPlayers.forEach(p => {
1338
+ const wallet = typeof p === 'string' ? p : (p.walletAddress || p.wallet);
1339
+ if (wallet) participantWallets.add(wallet);
1340
+ });
1341
+ }
1342
+ if (game.awayTeamPlayers && Array.isArray(game.awayTeamPlayers)) {
1343
+ game.awayTeamPlayers.forEach(p => {
1344
+ const wallet = typeof p === 'string' ? p : (p.walletAddress || p.wallet);
1345
+ if (wallet) participantWallets.add(wallet);
1346
+ });
1347
+ }
1348
+ if (game.drawTeamPlayers && Array.isArray(game.drawTeamPlayers)) {
1349
+ game.drawTeamPlayers.forEach(p => {
1350
+ const wallet = typeof p === 'string' ? p : (p.walletAddress || p.wallet);
1351
+ if (wallet) participantWallets.add(wallet);
1352
+ });
1353
+ }
1354
+ if (game.participants && Array.isArray(game.participants)) {
1355
+ game.participants.forEach(p => {
1356
+ const wallet = typeof p === 'string' ? p : (p.walletAddress || p.wallet);
1357
+ if (wallet) participantWallets.add(wallet);
1358
+ });
1359
+ }
1360
+
1361
+ if (participantWallets.size === 0) {
1362
+ console.log(' ℹ️ No participants found for notifications');
1363
+ return;
1364
+ }
1365
+
1366
+ // Send notification via dubs-server API (which handles PostgreSQL + WebSocket)
1367
+ const dubsServerUrl = this.dubsServerUrl;
1368
+
1369
+ for (const walletAddress of participantWallets) {
1370
+ try {
1371
+ // Skip if walletAddress is undefined or null
1372
+ if (!walletAddress) {
1373
+ console.log(' ⚠️ Skipping undefined wallet address');
1374
+ continue;
1375
+ }
1376
+
1377
+ await axios.post(
1378
+ `${dubsServerUrl}/api/games/notify-participant`,
1379
+ {
1380
+ walletAddress,
1381
+ notificationType,
1382
+ message,
1383
+ gameInvite,
1384
+ },
1385
+ {
1386
+ headers: { 'Content-Type': 'application/json' },
1387
+ timeout: 5000
1388
+ }
1389
+ );
1390
+ } catch (err) {
1391
+ console.log(` ⚠️ Failed to notify ${walletAddress?.slice(0, 8) || 'unknown'}:`, err.message);
1392
+ }
1393
+ }
1394
+
1395
+ console.log(` ✅ Sent ${notificationType} notifications to ${participantWallets.size} participant(s)`);
1396
+
1397
+ } catch (error) {
1398
+ console.error(' ⚠️ Failed to send web app notifications:', error.message);
1399
+ // Don't throw - notifications are non-critical
1400
+ }
1401
+ }
1402
+ // ============================================
1403
+ // ESPORTS NOTIFICATIONS
1404
+ // ============================================
1405
+
1406
+ /**
1407
+ * Send esports game result notifications (won/lost) to all participants.
1408
+ * Uses PandaScore opponent data instead of TheSportsDB fields.
1409
+ */
1410
+ async sendEsportsResultNotifications(game, result) {
1411
+ try {
1412
+ const se = game.sportsEvent || {};
1413
+ const opp0 = se.opponents?.[0]?.opponent || {};
1414
+ const opp1 = se.opponents?.[1]?.opponent || {};
1415
+
1416
+ console.log(` 🎮📧 Sending esports result notifications for ${game.gameId}`);
1417
+ console.log(` Game data:`, {
1418
+ hasEvent: !!se,
1419
+ homeTeam: opp0.name,
1420
+ awayTeam: opp1.name,
1421
+ buyIn: game.buyIn
1422
+ });
1423
+
1424
+ const gameInvite = {
1425
+ gameId: game.gameId,
1426
+ gameAddress: game.gameAddress || '',
1427
+ title: se.matchName || `${opp0.name || 'Team 1'} vs ${opp1.name || 'Team 2'}`,
1428
+ imageUrl: opp0.image_url || '',
1429
+ matchupImageUrl: game.matchupImageUrl || '',
1430
+ buyIn: game.buyIn || 0,
1431
+ league: se.videogame || se.videogameSlug || 'Esports',
1432
+ homeTeam: opp0.name,
1433
+ awayTeam: opp1.name,
1434
+ homeTeamBadge: opp0.image_url,
1435
+ awayTeamBadge: opp1.image_url,
1436
+ isEsports: true,
1437
+ tournament: se.tournament,
1438
+ videogameSlug: se.videogameSlug,
1439
+ };
1440
+
1441
+ const finalScore = {
1442
+ winner: result.winner,
1443
+ homeScore: result.homeScore,
1444
+ awayScore: result.awayScore,
1445
+ };
1446
+
1447
+ console.log(` 🎮📧 Built gameInvite:`, {
1448
+ title: gameInvite.title,
1449
+ buyIn: gameInvite.buyIn
1450
+ });
1451
+
1452
+ // Get all unique participant wallet addresses with their team choices
1453
+ const participants = [];
1454
+ if (game.homeTeamPlayers && Array.isArray(game.homeTeamPlayers)) {
1455
+ game.homeTeamPlayers.forEach(wallet => {
1456
+ if (typeof wallet === 'string') {
1457
+ participants.push({ wallet, teamChoice: 'home' });
1458
+ }
1459
+ });
1460
+ }
1461
+ if (game.awayTeamPlayers && Array.isArray(game.awayTeamPlayers)) {
1462
+ game.awayTeamPlayers.forEach(wallet => {
1463
+ if (typeof wallet === 'string') {
1464
+ participants.push({ wallet, teamChoice: 'away' });
1465
+ }
1466
+ });
1467
+ }
1468
+
1469
+ if (participants.length === 0) {
1470
+ console.log(' ℹ️ No participants found for esports result notifications');
1471
+ return;
1472
+ }
1473
+
1474
+ const dubsServerUrl = this.dubsServerUrl;
1475
+
1476
+ for (const participant of participants) {
1477
+ try {
1478
+ const userWon = participant.teamChoice === result.winner;
1479
+ const notificationType = userWon ? 'game_won' : 'game_lost';
1480
+ const message = `${result.homeScore}-${result.awayScore}`;
1481
+
1482
+ await axios.post(
1483
+ `${dubsServerUrl}/api/games/notify-participant`,
1484
+ {
1485
+ walletAddress: participant.wallet,
1486
+ notificationType,
1487
+ message,
1488
+ gameInvite,
1489
+ finalScore,
1490
+ },
1491
+ {
1492
+ headers: { 'Content-Type': 'application/json' },
1493
+ timeout: 5000
1494
+ }
1495
+ );
1496
+ } catch (err) {
1497
+ console.log(` ⚠️ Failed to notify ${participant.wallet?.slice(0, 8)}:`, err.message);
1498
+ }
1499
+ }
1500
+
1501
+ console.log(` ✅ Sent esports result notifications to ${participants.length} participant(s)`);
1502
+
1503
+ } catch (error) {
1504
+ console.error(' ⚠️ Failed to send esports result notifications:', error.message);
1505
+ }
1506
+ }
1507
+
1508
+ /**
1509
+ * Send esports web app notifications (starting soon / starting now).
1510
+ * Uses PandaScore opponent data instead of TheSportsDB fields.
1511
+ */
1512
+ async sendEsportsWebAppNotifications(game, notificationType, message) {
1513
+ try {
1514
+ const se = game.sportsEvent || {};
1515
+ const opp0 = se.opponents?.[0]?.opponent || {};
1516
+ const opp1 = se.opponents?.[1]?.opponent || {};
1517
+
1518
+ const gameInvite = {
1519
+ gameId: game.gameId,
1520
+ gameAddress: game.gameAddress || '',
1521
+ title: se.matchName || `${opp0.name || 'Team 1'} vs ${opp1.name || 'Team 2'}`,
1522
+ imageUrl: opp0.image_url || '',
1523
+ matchupImageUrl: game.matchupImageUrl || '',
1524
+ buyIn: game.buyIn || 0,
1525
+ totalPool: game.totalPool || 0,
1526
+ league: se.videogame || se.videogameSlug || 'Esports',
1527
+ homeTeam: opp0.name,
1528
+ awayTeam: opp1.name,
1529
+ homeTeamBadge: opp0.image_url,
1530
+ awayTeamBadge: opp1.image_url,
1531
+ strTimestamp: se.strTimestamp,
1532
+ isEsports: true,
1533
+ tournament: se.tournament,
1534
+ videogameSlug: se.videogameSlug,
1535
+ };
1536
+
1537
+ console.log(` 🎮📧 Built esports gameInvite for ${notificationType}:`, {
1538
+ title: gameInvite.title,
1539
+ buyIn: gameInvite.buyIn
1540
+ });
1541
+
1542
+ // Get all unique participant wallet addresses
1543
+ const participantWallets = new Set();
1544
+ if (game.homeTeamPlayers && Array.isArray(game.homeTeamPlayers)) {
1545
+ game.homeTeamPlayers.forEach(p => {
1546
+ const wallet = typeof p === 'string' ? p : (p.walletAddress || p.wallet);
1547
+ if (wallet) participantWallets.add(wallet);
1548
+ });
1549
+ }
1550
+ if (game.awayTeamPlayers && Array.isArray(game.awayTeamPlayers)) {
1551
+ game.awayTeamPlayers.forEach(p => {
1552
+ const wallet = typeof p === 'string' ? p : (p.walletAddress || p.wallet);
1553
+ if (wallet) participantWallets.add(wallet);
1554
+ });
1555
+ }
1556
+
1557
+ if (participantWallets.size === 0) {
1558
+ console.log(' ℹ️ No participants found for esports notifications');
1559
+ return;
1560
+ }
1561
+
1562
+ const dubsServerUrl = this.dubsServerUrl;
1563
+
1564
+ for (const walletAddress of participantWallets) {
1565
+ try {
1566
+ if (!walletAddress) continue;
1567
+
1568
+ await axios.post(
1569
+ `${dubsServerUrl}/api/games/notify-participant`,
1570
+ {
1571
+ walletAddress,
1572
+ notificationType,
1573
+ message,
1574
+ gameInvite,
1575
+ },
1576
+ {
1577
+ headers: { 'Content-Type': 'application/json' },
1578
+ timeout: 5000
1579
+ }
1580
+ );
1581
+ } catch (err) {
1582
+ console.log(` ⚠️ Failed to notify ${walletAddress?.slice(0, 8) || 'unknown'}:`, err.message);
1583
+ }
1584
+ }
1585
+
1586
+ console.log(` ✅ Sent esports ${notificationType} notifications to ${participantWallets.size} participant(s)`);
1587
+
1588
+ } catch (error) {
1589
+ console.error(' ⚠️ Failed to send esports web app notifications:', error.message);
1590
+ }
1591
+ }
1592
+ }
1593
+
1594
+ module.exports = AutomaticGameOracle;
1595
+ module.exports.normalizeLeague = normalizeLeague;
1596
+