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