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.
- package/.claude/settings.local.json +280 -0
- package/CLAUDE.md +46 -0
- package/CONNECT4_PRODUCTION_DEPLOY.md +155 -0
- package/CURRENT_SESSION.md +171 -0
- package/CURRENT_SESSION_DRAW.md +516 -0
- package/MARCH_MADNESS_SURVIVOR.md +254 -0
- package/PANDA.md +166 -0
- package/Procfile +4 -0
- package/README.md +476 -0
- package/controllers/livescoresController.js +376 -0
- package/controllers/pickemController.js +554 -0
- package/controllers/survivorAdminController.js +887 -0
- package/controllers/survivorController.js +623 -0
- package/cron/oracleMonitor.js +77 -0
- package/cron/pickemOracleMonitor.js +73 -0
- package/data/jackpot-history.json +952 -0
- package/data/ncaaTeams.js +406 -0
- package/documentation/API_SECURITY_GUIDE.md +327 -0
- package/documentation/ARCADE_API.md +593 -0
- package/documentation/ARCADE_IMPLEMENTATION_SUMMARY.md +399 -0
- package/documentation/ARCADE_QUICKSTART.md +242 -0
- package/documentation/AUTOMATIC_MODE_ORACLE.md +321 -0
- package/documentation/BUG_FIX_COHORT_DATE_DISPLAY.md +171 -0
- package/documentation/CLAIM_MIGRATION_INSTRUCTIONS.md +52 -0
- package/documentation/CLAIM_STATUS_FIX.md +67 -0
- package/documentation/CLI_TOOL_GUIDE.md +372 -0
- package/documentation/COHORT_RETENTION_ANALYSIS.md +295 -0
- package/documentation/COHORT_RETENTION_IMPLEMENTATION_COMPLETE.md +461 -0
- package/documentation/COHORT_RETENTION_SUMMARY.md +204 -0
- package/documentation/COMPLETE_PROJECT_SUMMARY.md +490 -0
- package/documentation/DATABASE_QUERIES.md +269 -0
- package/documentation/DATABASE_RETENTION_POLICY.md +390 -0
- package/documentation/DATABASE_SETUP_GUIDE.md +361 -0
- package/documentation/DATABASE_SETUP_SUMMARY.md +247 -0
- package/documentation/DEMO_API_CURL_COMMANDS.md +656 -0
- package/documentation/DEPLOYMENT_SUMMARY.txt +100 -0
- package/documentation/DUPLICATE_NOTIFICATIONS_FIXED.md +201 -0
- package/documentation/EXCHANGE_RATES_INTEGRATION.md +371 -0
- package/documentation/FINAL_API_PROTECTION_TABLE.md +175 -0
- package/documentation/GAME_START_NOTIFICATIONS_DEPLOYMENT.md +256 -0
- package/documentation/GAME_START_NOTIFICATIONS_INTEGRATION.md +275 -0
- package/documentation/HEROKU_DEPLOYMENT.md +134 -0
- package/documentation/HEROKU_SCHEDULER_SETUP.md +271 -0
- package/documentation/JACKPOT_API.md +521 -0
- package/documentation/JACKPOT_DEPLOYMENT_GUIDE.md +362 -0
- package/documentation/JWT_IMPLEMENTATION_SUMMARY.md +373 -0
- package/documentation/JWT_QUICK_SETUP.md +268 -0
- package/documentation/JWT_TESTING_GUIDE.md +404 -0
- package/documentation/KEEPER_RECOVERY_GUIDE.md +381 -0
- package/documentation/KEEPER_SETUP.md +206 -0
- package/documentation/KEEPER_STATE_MACHINE.md +423 -0
- package/documentation/LATEST_PRODUCTION_SETUP.md +387 -0
- package/documentation/LOCAL_VOTING_TEST.md +279 -0
- package/documentation/ORACLE_FIXES_SUMMARY.md +188 -0
- package/documentation/ORACLE_POSTGRESQL_UPDATE.md +202 -0
- package/documentation/PAYMENT_DEPLOYMENT.md +209 -0
- package/documentation/PNL_TRACKING_SETUP.md +189 -0
- package/documentation/PREVENTING_LOCKUP_ERRORS.md +472 -0
- package/documentation/PRODUCTION_READY_SUMMARY.md +227 -0
- package/documentation/PUBLIC_VS_PRIVATE_ENDPOINTS.md +278 -0
- package/documentation/QUICK_AUTH_SETUP.md +99 -0
- package/documentation/QUICK_DEPLOY.md +224 -0
- package/documentation/QUICK_FIX.md +114 -0
- package/documentation/QUICK_START.md +152 -0
- package/documentation/REFEREE_MODE_GUIDE.md +392 -0
- package/documentation/RETENTION_CORE_ACTION_UPDATE.md +313 -0
- package/documentation/RETENTION_UPDATE_SUMMARY.md +108 -0
- package/documentation/RUN_MIGRATION_NOW.md +39 -0
- package/documentation/SCRIPTS_UPDATE_SUMMARY.md +251 -0
- package/documentation/SETUP_GUIDE.md +184 -0
- package/documentation/STATE_MACHINE_IMPLEMENTATION.md +250 -0
- package/documentation/TELEGRAM_NOTIFICATIONS_DIAGNOSIS.md +361 -0
- package/documentation/UNIFIED_ARCHITECTURE.md +231 -0
- package/documentation/VOTING_DEPLOYMENT_SUMMARY.md +392 -0
- package/documentation/WEBSOCKET_ARCHITECTURE.md +881 -0
- package/documentation/WHAT_WE_BUILT_TODAY.md +369 -0
- package/documentation/latest/LATEST_PRODUCTION_SETUP.md +865 -0
- package/ecosystem.config.js +65 -0
- package/env.template +125 -0
- package/middleware/apiKeyAuth.js +136 -0
- package/middleware/authenticate.js +214 -0
- package/middleware/developerUserAuth.js +76 -0
- package/middleware/socketAuth.js +69 -0
- package/package.json +49 -0
- package/postman/Dubs-API-v1-With-Voting.postman_collection.json +555 -0
- package/postman/Dubs-API-v1.postman_collection.json +205 -0
- package/postman/Dubs_Developer_API.postman_collection.json +662 -0
- package/postman/QUICKSTART.md +118 -0
- package/postman/QUICK_REFERENCE.md +246 -0
- package/postman/README.md +71 -0
- package/postman/VOTING_API_GUIDE.md +426 -0
- package/refactor/Animations.md +148 -0
- package/refactor/Chat.md +252 -0
- package/routes/actionsRoutes.js +699 -0
- package/routes/adminRoutes.js +370 -0
- package/routes/analyticsRoutes.js +1262 -0
- package/routes/arcadeRoutes.js +557 -0
- package/routes/authRoutes.js +2310 -0
- package/routes/avatarRoutes.js +85 -0
- package/routes/botRoutes.js +211 -0
- package/routes/chatRoutes.js +377 -0
- package/routes/cryptoPriceRoutes.js +105 -0
- package/routes/developerRoutes.js +4201 -0
- package/routes/deviceRoutes.js +214 -0
- package/routes/dmRoutes.js +167 -0
- package/routes/esportsRoutes.js +806 -0
- package/routes/exchangeRateRoutes.js +233 -0
- package/routes/gamesRoutes.js +3028 -0
- package/routes/jackpotRoutes.js +754 -0
- package/routes/keeperMonitoringRoutes.js +156 -0
- package/routes/keeperWebhookRoutes.js +466 -0
- package/routes/livescoresRoutes.js +31 -0
- package/routes/pickemAdminRoutes.js +199 -0
- package/routes/pickemRoutes.js +231 -0
- package/routes/playerStatsRoutes.js +147 -0
- package/routes/portfolioRoutes.js +217 -0
- package/routes/promoRoutes.js +418 -0
- package/routes/referralEarningsRoutes.js +392 -0
- package/routes/socialRoutes.js +459 -0
- package/routes/sportsRoutes.js +1271 -0
- package/routes/survivorAdminRoutes.js +345 -0
- package/routes/survivorRoutes.js +756 -0
- package/routes/uploadRoutes.js +256 -0
- package/routes/userProfileRoutes.js +244 -0
- package/routes/whatsNewRoutes.js +331 -0
- package/scripts/.claude/settings.local.json +15 -0
- package/scripts/README.md +170 -0
- package/scripts/RESTART_EVERYTHING.sh +104 -0
- package/scripts/add-claim-columns.sql +48 -0
- package/scripts/add-crypto-prices-cache.sql +27 -0
- package/scripts/add-exchange-rates-cache.sql +40 -0
- package/scripts/add-game-invite-column.sql +23 -0
- package/scripts/add-game-invite-notification.sql +33 -0
- package/scripts/add-game-invite-telegram-pref.sql +16 -0
- package/scripts/add-game-joined-notification.sql +16 -0
- package/scripts/add-game-joined-pref.js +40 -0
- package/scripts/add-game-joined-preference.sql +6 -0
- package/scripts/add-game-start-notifications.sql +41 -0
- package/scripts/add-notification-flags-to-games.sql +55 -0
- package/scripts/add-pending-game-dismissals.sql +19 -0
- package/scripts/add-preferred-currency.sql +34 -0
- package/scripts/add-winner-columns.js +61 -0
- package/scripts/add_mention_system.sql +53 -0
- package/scripts/add_payment_system.sql +96 -0
- package/scripts/add_sports_event_id_column.sql +22 -0
- package/scripts/analyze-cohort-data-heroku.js +276 -0
- package/scripts/analyze-cohort-data.js +295 -0
- package/scripts/analyze-prod-cohorts.sh +10 -0
- package/scripts/backfill-matchup-images.js +245 -0
- package/scripts/backfill-missing-signatures.js +175 -0
- package/scripts/backfill-referral-earnings.js +202 -0
- package/scripts/check-chat-schema.js +130 -0
- package/scripts/check-db.sh +14 -0
- package/scripts/check_oracle_in_game.js +54 -0
- package/scripts/cleanup-database.js +193 -0
- package/scripts/clear-notification-cache.js +85 -0
- package/scripts/convert-mnemonic.js +50 -0
- package/scripts/create-users-table.sql +44 -0
- package/scripts/debug-cohort-counts.js +248 -0
- package/scripts/debug-winner-calc.js +84 -0
- package/scripts/deploy-payment-system.sh +118 -0
- package/scripts/deploy-to-heroku.sh +63 -0
- package/scripts/diagnose-locked-round.js +143 -0
- package/scripts/dubs-cli.js +720 -0
- package/scripts/dump-account.js +65 -0
- package/scripts/find-vrf-offset.js +48 -0
- package/scripts/fix-chat-notifications-constraint.sql +122 -0
- package/scripts/fix-claim-columns.js +124 -0
- package/scripts/fix-constraint-now.js +44 -0
- package/scripts/fix-lock-timestamps.js +96 -0
- package/scripts/fix-locked-round.sh +126 -0
- package/scripts/fix-missing-badges.sql +91 -0
- package/scripts/fix-payment-notifications.sql +41 -0
- package/scripts/force-new-round.js +55 -0
- package/scripts/force-resolve-and-claim.js +278 -0
- package/scripts/important/README.md +115 -0
- package/scripts/important/authority-force-lock.js +197 -0
- package/scripts/important/authority-resolve-game.js +267 -0
- package/scripts/important/check-game-status.js +373 -0
- package/scripts/important/list-pending-games-by-version.js +270 -0
- package/scripts/important/reconcile-v1-v2-payouts.js +270 -0
- package/scripts/initialize-jackpot.js +111 -0
- package/scripts/jackpot/.claude/settings.local.json +10 -0
- package/scripts/jackpot/force-reset.js +84 -0
- package/scripts/jackpot/initialize-mainnet.js +100 -0
- package/scripts/jackpot/keeper.js +742 -0
- package/scripts/jackpot/status.js +107 -0
- package/scripts/jackpot/update-round-duration.js +143 -0
- package/scripts/keeper-bot.js +112 -0
- package/scripts/list-pending-games.js +131 -0
- package/scripts/migrate-chat-v2.js +127 -0
- package/scripts/migrate-chat-winners.js +84 -0
- package/scripts/migrate-chat.sh +17 -0
- package/scripts/migrate-game-invite.js +83 -0
- package/scripts/migrate-heroku-game-notifications.sh +159 -0
- package/scripts/migrations/001_analytics_tables.sql +422 -0
- package/scripts/migrations/002_add_matchup_image_url.sql +14 -0
- package/scripts/migrations/003_referral_earnings.sql +208 -0
- package/scripts/migrations/004_add_whats_new_notification_type.sql +62 -0
- package/scripts/migrations/005_add_connect4_your_turn_notification.sql +61 -0
- package/scripts/migrations/005_push_notifications.sql +55 -0
- package/scripts/migrations/006_add_draw_team_players.sql +28 -0
- package/scripts/migrations/006_add_game_cancelled_notification.sql +62 -0
- package/scripts/migrations/007_add_gif_url.sql +8 -0
- package/scripts/migrations/008_add_connect4_columns.sql +139 -0
- package/scripts/migrations/008_add_pool_tracking.sql +22 -0
- package/scripts/migrations/009_create_survivor_pool_tables.sql +174 -0
- package/scripts/migrations/010_add_survivor_pool_outcome.sql +28 -0
- package/scripts/migrations/011_create_developer_tables.sql +67 -0
- package/scripts/migrations/011_fix_keeper_tables.sql +85 -0
- package/scripts/migrations/012_create_developer_webhooks.sql +31 -0
- package/scripts/migrations/013_add_network_mode.sql +18 -0
- package/scripts/migrations/014_create_developer_app_users.sql +19 -0
- package/scripts/migrations/015_add_ui_config.sql +4 -0
- package/scripts/migrations/016_add_resolution_secret.sql +4 -0
- package/scripts/migrations/017_add_external_game_id.sql +3 -0
- package/scripts/migrations/018_create_pickem_tables.sql +115 -0
- package/scripts/migrations/019_expo_push_tokens.sql +19 -0
- package/scripts/migrations/create_whats_new_tables.sql +88 -0
- package/scripts/migrations/drop_live_games_tables.sql +34 -0
- package/scripts/open-jackpot-round.js +85 -0
- package/scripts/purge-all-data.sh +329 -0
- package/scripts/purge-all-data.sql +142 -0
- package/scripts/purge-heroku-data.sh +149 -0
- package/scripts/purge-heroku-data.sql +62 -0
- package/scripts/rebuild-heroku-database.sh +113 -0
- package/scripts/recover-funds.js +357 -0
- package/scripts/regenerate-epl-images.js +278 -0
- package/scripts/resize-s3-matchup-images.js +374 -0
- package/scripts/resolve-direct.js +88 -0
- package/scripts/resolve-mock-game.js +124 -0
- package/scripts/resolve-pickem-game.js +55 -0
- package/scripts/resolve-round-manual.js +83 -0
- package/scripts/resolve-stuck-game.js +382 -0
- package/scripts/resolve-stuck-round.js +42 -0
- package/scripts/run-connect4-migration.sh +16 -0
- package/scripts/run-mention-migration.sh +32 -0
- package/scripts/run-payment-migration.sh +51 -0
- package/scripts/run-preferred-currency-migration.sh +31 -0
- package/scripts/run-referral-earnings-migration.sh +32 -0
- package/scripts/run-survivor-outcome-migration.sh +16 -0
- package/scripts/seed-test-users.js +346 -0
- package/scripts/setup-auth-tables.js +78 -0
- package/scripts/setup-complete-database.sql +992 -0
- package/scripts/setup-database-fresh.sh +359 -0
- package/scripts/setup-heroku-keeper.sh +48 -0
- package/scripts/setup-keeper-database.js +83 -0
- package/scripts/setup-keeper-state-db.sql +110 -0
- package/scripts/setup-oracle.sh +39 -0
- package/scripts/setup-pnl-tracking.js +111 -0
- package/scripts/start-devnet.sh +14 -0
- package/scripts/test-arcade-devnet.sh +160 -0
- package/scripts/test-arcade-match.sh +109 -0
- package/scripts/test-automatic-mode.sh +239 -0
- package/scripts/test-connect4-cancel-claim.js +370 -0
- package/scripts/test-connect4-e2e.js +369 -0
- package/scripts/test-connect4-resolve.js +369 -0
- package/scripts/test-game-state-endpoint.js +136 -0
- package/scripts/test-invite-notification.js +86 -0
- package/scripts/test-jackpot-api.sh +71 -0
- package/scripts/test-poll-confirmation.js +267 -0
- package/scripts/test-resolve-game.js +271 -0
- package/scripts/test-resolve-signature.js +223 -0
- package/scripts/test-signature-preservation.js +124 -0
- package/scripts/test-state-machine.js +291 -0
- package/scripts/test-webhook-receiver.js +60 -0
- package/scripts/update-notification-constraint.js +52 -0
- package/scripts/verify-account-layout.js +145 -0
- package/scripts/verify-winner-algorithm.js +278 -0
- package/server.js +5259 -0
- package/services/arcadeMatchService.js +763 -0
- package/services/automaticGameOracle.js +1596 -0
- package/services/chatService.js +1612 -0
- package/services/connect4GameService.js +1049 -0
- package/services/connect4NotificationService.js +374 -0
- package/services/cryptoPriceService.js +223 -0
- package/services/customGameResolver.js +260 -0
- package/services/db.js +79 -0
- package/services/directMessageService.js +389 -0
- package/services/discordNotifications.js +160 -0
- package/services/exchangeRateService.js +289 -0
- package/services/expoPushService.js +314 -0
- package/services/gamesCacheService.js +539 -0
- package/services/jackpotHistory.js +331 -0
- package/services/jackpotService.js +856 -0
- package/services/keeperStateService.js +355 -0
- package/services/matchupImageService.js +591 -0
- package/services/notificationCacheService.js +407 -0
- package/services/pickemOracle.js +440 -0
- package/services/playerStatsService.js +389 -0
- package/services/portfolioService.js +555 -0
- package/services/promoService.js +757 -0
- package/services/promoTreasuryService.js +239 -0
- package/services/pushNotifications.js +353 -0
- package/services/redisService.js +422 -0
- package/services/referralEarningsService.js +728 -0
- package/services/s3Service.js +396 -0
- package/services/socialService.js +1202 -0
- package/services/survivorOracle.js +469 -0
- package/services/survivorSimulator.js +475 -0
- package/services/telegramNotifications.js +461 -0
- package/services/userProfileStatsService.js +1185 -0
- package/services/whatsNewService.js +388 -0
- 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
|
+
|