dubs-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/.claude/settings.local.json +280 -0
  2. package/CLAUDE.md +46 -0
  3. package/CONNECT4_PRODUCTION_DEPLOY.md +155 -0
  4. package/CURRENT_SESSION.md +171 -0
  5. package/CURRENT_SESSION_DRAW.md +516 -0
  6. package/MARCH_MADNESS_SURVIVOR.md +254 -0
  7. package/PANDA.md +166 -0
  8. package/Procfile +4 -0
  9. package/README.md +476 -0
  10. package/controllers/livescoresController.js +376 -0
  11. package/controllers/pickemController.js +554 -0
  12. package/controllers/survivorAdminController.js +887 -0
  13. package/controllers/survivorController.js +623 -0
  14. package/cron/oracleMonitor.js +77 -0
  15. package/cron/pickemOracleMonitor.js +73 -0
  16. package/data/jackpot-history.json +952 -0
  17. package/data/ncaaTeams.js +406 -0
  18. package/documentation/API_SECURITY_GUIDE.md +327 -0
  19. package/documentation/ARCADE_API.md +593 -0
  20. package/documentation/ARCADE_IMPLEMENTATION_SUMMARY.md +399 -0
  21. package/documentation/ARCADE_QUICKSTART.md +242 -0
  22. package/documentation/AUTOMATIC_MODE_ORACLE.md +321 -0
  23. package/documentation/BUG_FIX_COHORT_DATE_DISPLAY.md +171 -0
  24. package/documentation/CLAIM_MIGRATION_INSTRUCTIONS.md +52 -0
  25. package/documentation/CLAIM_STATUS_FIX.md +67 -0
  26. package/documentation/CLI_TOOL_GUIDE.md +372 -0
  27. package/documentation/COHORT_RETENTION_ANALYSIS.md +295 -0
  28. package/documentation/COHORT_RETENTION_IMPLEMENTATION_COMPLETE.md +461 -0
  29. package/documentation/COHORT_RETENTION_SUMMARY.md +204 -0
  30. package/documentation/COMPLETE_PROJECT_SUMMARY.md +490 -0
  31. package/documentation/DATABASE_QUERIES.md +269 -0
  32. package/documentation/DATABASE_RETENTION_POLICY.md +390 -0
  33. package/documentation/DATABASE_SETUP_GUIDE.md +361 -0
  34. package/documentation/DATABASE_SETUP_SUMMARY.md +247 -0
  35. package/documentation/DEMO_API_CURL_COMMANDS.md +656 -0
  36. package/documentation/DEPLOYMENT_SUMMARY.txt +100 -0
  37. package/documentation/DUPLICATE_NOTIFICATIONS_FIXED.md +201 -0
  38. package/documentation/EXCHANGE_RATES_INTEGRATION.md +371 -0
  39. package/documentation/FINAL_API_PROTECTION_TABLE.md +175 -0
  40. package/documentation/GAME_START_NOTIFICATIONS_DEPLOYMENT.md +256 -0
  41. package/documentation/GAME_START_NOTIFICATIONS_INTEGRATION.md +275 -0
  42. package/documentation/HEROKU_DEPLOYMENT.md +134 -0
  43. package/documentation/HEROKU_SCHEDULER_SETUP.md +271 -0
  44. package/documentation/JACKPOT_API.md +521 -0
  45. package/documentation/JACKPOT_DEPLOYMENT_GUIDE.md +362 -0
  46. package/documentation/JWT_IMPLEMENTATION_SUMMARY.md +373 -0
  47. package/documentation/JWT_QUICK_SETUP.md +268 -0
  48. package/documentation/JWT_TESTING_GUIDE.md +404 -0
  49. package/documentation/KEEPER_RECOVERY_GUIDE.md +381 -0
  50. package/documentation/KEEPER_SETUP.md +206 -0
  51. package/documentation/KEEPER_STATE_MACHINE.md +423 -0
  52. package/documentation/LATEST_PRODUCTION_SETUP.md +387 -0
  53. package/documentation/LOCAL_VOTING_TEST.md +279 -0
  54. package/documentation/ORACLE_FIXES_SUMMARY.md +188 -0
  55. package/documentation/ORACLE_POSTGRESQL_UPDATE.md +202 -0
  56. package/documentation/PAYMENT_DEPLOYMENT.md +209 -0
  57. package/documentation/PNL_TRACKING_SETUP.md +189 -0
  58. package/documentation/PREVENTING_LOCKUP_ERRORS.md +472 -0
  59. package/documentation/PRODUCTION_READY_SUMMARY.md +227 -0
  60. package/documentation/PUBLIC_VS_PRIVATE_ENDPOINTS.md +278 -0
  61. package/documentation/QUICK_AUTH_SETUP.md +99 -0
  62. package/documentation/QUICK_DEPLOY.md +224 -0
  63. package/documentation/QUICK_FIX.md +114 -0
  64. package/documentation/QUICK_START.md +152 -0
  65. package/documentation/REFEREE_MODE_GUIDE.md +392 -0
  66. package/documentation/RETENTION_CORE_ACTION_UPDATE.md +313 -0
  67. package/documentation/RETENTION_UPDATE_SUMMARY.md +108 -0
  68. package/documentation/RUN_MIGRATION_NOW.md +39 -0
  69. package/documentation/SCRIPTS_UPDATE_SUMMARY.md +251 -0
  70. package/documentation/SETUP_GUIDE.md +184 -0
  71. package/documentation/STATE_MACHINE_IMPLEMENTATION.md +250 -0
  72. package/documentation/TELEGRAM_NOTIFICATIONS_DIAGNOSIS.md +361 -0
  73. package/documentation/UNIFIED_ARCHITECTURE.md +231 -0
  74. package/documentation/VOTING_DEPLOYMENT_SUMMARY.md +392 -0
  75. package/documentation/WEBSOCKET_ARCHITECTURE.md +881 -0
  76. package/documentation/WHAT_WE_BUILT_TODAY.md +369 -0
  77. package/documentation/latest/LATEST_PRODUCTION_SETUP.md +865 -0
  78. package/ecosystem.config.js +65 -0
  79. package/env.template +125 -0
  80. package/middleware/apiKeyAuth.js +136 -0
  81. package/middleware/authenticate.js +214 -0
  82. package/middleware/developerUserAuth.js +76 -0
  83. package/middleware/socketAuth.js +69 -0
  84. package/package.json +49 -0
  85. package/postman/Dubs-API-v1-With-Voting.postman_collection.json +555 -0
  86. package/postman/Dubs-API-v1.postman_collection.json +205 -0
  87. package/postman/Dubs_Developer_API.postman_collection.json +662 -0
  88. package/postman/QUICKSTART.md +118 -0
  89. package/postman/QUICK_REFERENCE.md +246 -0
  90. package/postman/README.md +71 -0
  91. package/postman/VOTING_API_GUIDE.md +426 -0
  92. package/refactor/Animations.md +148 -0
  93. package/refactor/Chat.md +252 -0
  94. package/routes/actionsRoutes.js +699 -0
  95. package/routes/adminRoutes.js +370 -0
  96. package/routes/analyticsRoutes.js +1262 -0
  97. package/routes/arcadeRoutes.js +557 -0
  98. package/routes/authRoutes.js +2310 -0
  99. package/routes/avatarRoutes.js +85 -0
  100. package/routes/botRoutes.js +211 -0
  101. package/routes/chatRoutes.js +377 -0
  102. package/routes/cryptoPriceRoutes.js +105 -0
  103. package/routes/developerRoutes.js +4201 -0
  104. package/routes/deviceRoutes.js +214 -0
  105. package/routes/dmRoutes.js +167 -0
  106. package/routes/esportsRoutes.js +806 -0
  107. package/routes/exchangeRateRoutes.js +233 -0
  108. package/routes/gamesRoutes.js +3028 -0
  109. package/routes/jackpotRoutes.js +754 -0
  110. package/routes/keeperMonitoringRoutes.js +156 -0
  111. package/routes/keeperWebhookRoutes.js +466 -0
  112. package/routes/livescoresRoutes.js +31 -0
  113. package/routes/pickemAdminRoutes.js +199 -0
  114. package/routes/pickemRoutes.js +231 -0
  115. package/routes/playerStatsRoutes.js +147 -0
  116. package/routes/portfolioRoutes.js +217 -0
  117. package/routes/promoRoutes.js +418 -0
  118. package/routes/referralEarningsRoutes.js +392 -0
  119. package/routes/socialRoutes.js +459 -0
  120. package/routes/sportsRoutes.js +1271 -0
  121. package/routes/survivorAdminRoutes.js +345 -0
  122. package/routes/survivorRoutes.js +756 -0
  123. package/routes/uploadRoutes.js +256 -0
  124. package/routes/userProfileRoutes.js +244 -0
  125. package/routes/whatsNewRoutes.js +331 -0
  126. package/scripts/.claude/settings.local.json +15 -0
  127. package/scripts/README.md +170 -0
  128. package/scripts/RESTART_EVERYTHING.sh +104 -0
  129. package/scripts/add-claim-columns.sql +48 -0
  130. package/scripts/add-crypto-prices-cache.sql +27 -0
  131. package/scripts/add-exchange-rates-cache.sql +40 -0
  132. package/scripts/add-game-invite-column.sql +23 -0
  133. package/scripts/add-game-invite-notification.sql +33 -0
  134. package/scripts/add-game-invite-telegram-pref.sql +16 -0
  135. package/scripts/add-game-joined-notification.sql +16 -0
  136. package/scripts/add-game-joined-pref.js +40 -0
  137. package/scripts/add-game-joined-preference.sql +6 -0
  138. package/scripts/add-game-start-notifications.sql +41 -0
  139. package/scripts/add-notification-flags-to-games.sql +55 -0
  140. package/scripts/add-pending-game-dismissals.sql +19 -0
  141. package/scripts/add-preferred-currency.sql +34 -0
  142. package/scripts/add-winner-columns.js +61 -0
  143. package/scripts/add_mention_system.sql +53 -0
  144. package/scripts/add_payment_system.sql +96 -0
  145. package/scripts/add_sports_event_id_column.sql +22 -0
  146. package/scripts/analyze-cohort-data-heroku.js +276 -0
  147. package/scripts/analyze-cohort-data.js +295 -0
  148. package/scripts/analyze-prod-cohorts.sh +10 -0
  149. package/scripts/backfill-matchup-images.js +245 -0
  150. package/scripts/backfill-missing-signatures.js +175 -0
  151. package/scripts/backfill-referral-earnings.js +202 -0
  152. package/scripts/check-chat-schema.js +130 -0
  153. package/scripts/check-db.sh +14 -0
  154. package/scripts/check_oracle_in_game.js +54 -0
  155. package/scripts/cleanup-database.js +193 -0
  156. package/scripts/clear-notification-cache.js +85 -0
  157. package/scripts/convert-mnemonic.js +50 -0
  158. package/scripts/create-users-table.sql +44 -0
  159. package/scripts/debug-cohort-counts.js +248 -0
  160. package/scripts/debug-winner-calc.js +84 -0
  161. package/scripts/deploy-payment-system.sh +118 -0
  162. package/scripts/deploy-to-heroku.sh +63 -0
  163. package/scripts/diagnose-locked-round.js +143 -0
  164. package/scripts/dubs-cli.js +720 -0
  165. package/scripts/dump-account.js +65 -0
  166. package/scripts/find-vrf-offset.js +48 -0
  167. package/scripts/fix-chat-notifications-constraint.sql +122 -0
  168. package/scripts/fix-claim-columns.js +124 -0
  169. package/scripts/fix-constraint-now.js +44 -0
  170. package/scripts/fix-lock-timestamps.js +96 -0
  171. package/scripts/fix-locked-round.sh +126 -0
  172. package/scripts/fix-missing-badges.sql +91 -0
  173. package/scripts/fix-payment-notifications.sql +41 -0
  174. package/scripts/force-new-round.js +55 -0
  175. package/scripts/force-resolve-and-claim.js +278 -0
  176. package/scripts/important/README.md +115 -0
  177. package/scripts/important/authority-force-lock.js +197 -0
  178. package/scripts/important/authority-resolve-game.js +267 -0
  179. package/scripts/important/check-game-status.js +373 -0
  180. package/scripts/important/list-pending-games-by-version.js +270 -0
  181. package/scripts/important/reconcile-v1-v2-payouts.js +270 -0
  182. package/scripts/initialize-jackpot.js +111 -0
  183. package/scripts/jackpot/.claude/settings.local.json +10 -0
  184. package/scripts/jackpot/force-reset.js +84 -0
  185. package/scripts/jackpot/initialize-mainnet.js +100 -0
  186. package/scripts/jackpot/keeper.js +742 -0
  187. package/scripts/jackpot/status.js +107 -0
  188. package/scripts/jackpot/update-round-duration.js +143 -0
  189. package/scripts/keeper-bot.js +112 -0
  190. package/scripts/list-pending-games.js +131 -0
  191. package/scripts/migrate-chat-v2.js +127 -0
  192. package/scripts/migrate-chat-winners.js +84 -0
  193. package/scripts/migrate-chat.sh +17 -0
  194. package/scripts/migrate-game-invite.js +83 -0
  195. package/scripts/migrate-heroku-game-notifications.sh +159 -0
  196. package/scripts/migrations/001_analytics_tables.sql +422 -0
  197. package/scripts/migrations/002_add_matchup_image_url.sql +14 -0
  198. package/scripts/migrations/003_referral_earnings.sql +208 -0
  199. package/scripts/migrations/004_add_whats_new_notification_type.sql +62 -0
  200. package/scripts/migrations/005_add_connect4_your_turn_notification.sql +61 -0
  201. package/scripts/migrations/005_push_notifications.sql +55 -0
  202. package/scripts/migrations/006_add_draw_team_players.sql +28 -0
  203. package/scripts/migrations/006_add_game_cancelled_notification.sql +62 -0
  204. package/scripts/migrations/007_add_gif_url.sql +8 -0
  205. package/scripts/migrations/008_add_connect4_columns.sql +139 -0
  206. package/scripts/migrations/008_add_pool_tracking.sql +22 -0
  207. package/scripts/migrations/009_create_survivor_pool_tables.sql +174 -0
  208. package/scripts/migrations/010_add_survivor_pool_outcome.sql +28 -0
  209. package/scripts/migrations/011_create_developer_tables.sql +67 -0
  210. package/scripts/migrations/011_fix_keeper_tables.sql +85 -0
  211. package/scripts/migrations/012_create_developer_webhooks.sql +31 -0
  212. package/scripts/migrations/013_add_network_mode.sql +18 -0
  213. package/scripts/migrations/014_create_developer_app_users.sql +19 -0
  214. package/scripts/migrations/015_add_ui_config.sql +4 -0
  215. package/scripts/migrations/016_add_resolution_secret.sql +4 -0
  216. package/scripts/migrations/017_add_external_game_id.sql +3 -0
  217. package/scripts/migrations/018_create_pickem_tables.sql +115 -0
  218. package/scripts/migrations/019_expo_push_tokens.sql +19 -0
  219. package/scripts/migrations/create_whats_new_tables.sql +88 -0
  220. package/scripts/migrations/drop_live_games_tables.sql +34 -0
  221. package/scripts/open-jackpot-round.js +85 -0
  222. package/scripts/purge-all-data.sh +329 -0
  223. package/scripts/purge-all-data.sql +142 -0
  224. package/scripts/purge-heroku-data.sh +149 -0
  225. package/scripts/purge-heroku-data.sql +62 -0
  226. package/scripts/rebuild-heroku-database.sh +113 -0
  227. package/scripts/recover-funds.js +357 -0
  228. package/scripts/regenerate-epl-images.js +278 -0
  229. package/scripts/resize-s3-matchup-images.js +374 -0
  230. package/scripts/resolve-direct.js +88 -0
  231. package/scripts/resolve-mock-game.js +124 -0
  232. package/scripts/resolve-pickem-game.js +55 -0
  233. package/scripts/resolve-round-manual.js +83 -0
  234. package/scripts/resolve-stuck-game.js +382 -0
  235. package/scripts/resolve-stuck-round.js +42 -0
  236. package/scripts/run-connect4-migration.sh +16 -0
  237. package/scripts/run-mention-migration.sh +32 -0
  238. package/scripts/run-payment-migration.sh +51 -0
  239. package/scripts/run-preferred-currency-migration.sh +31 -0
  240. package/scripts/run-referral-earnings-migration.sh +32 -0
  241. package/scripts/run-survivor-outcome-migration.sh +16 -0
  242. package/scripts/seed-test-users.js +346 -0
  243. package/scripts/setup-auth-tables.js +78 -0
  244. package/scripts/setup-complete-database.sql +992 -0
  245. package/scripts/setup-database-fresh.sh +359 -0
  246. package/scripts/setup-heroku-keeper.sh +48 -0
  247. package/scripts/setup-keeper-database.js +83 -0
  248. package/scripts/setup-keeper-state-db.sql +110 -0
  249. package/scripts/setup-oracle.sh +39 -0
  250. package/scripts/setup-pnl-tracking.js +111 -0
  251. package/scripts/start-devnet.sh +14 -0
  252. package/scripts/test-arcade-devnet.sh +160 -0
  253. package/scripts/test-arcade-match.sh +109 -0
  254. package/scripts/test-automatic-mode.sh +239 -0
  255. package/scripts/test-connect4-cancel-claim.js +370 -0
  256. package/scripts/test-connect4-e2e.js +369 -0
  257. package/scripts/test-connect4-resolve.js +369 -0
  258. package/scripts/test-game-state-endpoint.js +136 -0
  259. package/scripts/test-invite-notification.js +86 -0
  260. package/scripts/test-jackpot-api.sh +71 -0
  261. package/scripts/test-poll-confirmation.js +267 -0
  262. package/scripts/test-resolve-game.js +271 -0
  263. package/scripts/test-resolve-signature.js +223 -0
  264. package/scripts/test-signature-preservation.js +124 -0
  265. package/scripts/test-state-machine.js +291 -0
  266. package/scripts/test-webhook-receiver.js +60 -0
  267. package/scripts/update-notification-constraint.js +52 -0
  268. package/scripts/verify-account-layout.js +145 -0
  269. package/scripts/verify-winner-algorithm.js +278 -0
  270. package/server.js +5259 -0
  271. package/services/arcadeMatchService.js +763 -0
  272. package/services/automaticGameOracle.js +1596 -0
  273. package/services/chatService.js +1612 -0
  274. package/services/connect4GameService.js +1049 -0
  275. package/services/connect4NotificationService.js +374 -0
  276. package/services/cryptoPriceService.js +223 -0
  277. package/services/customGameResolver.js +260 -0
  278. package/services/db.js +79 -0
  279. package/services/directMessageService.js +389 -0
  280. package/services/discordNotifications.js +160 -0
  281. package/services/exchangeRateService.js +289 -0
  282. package/services/expoPushService.js +314 -0
  283. package/services/gamesCacheService.js +539 -0
  284. package/services/jackpotHistory.js +331 -0
  285. package/services/jackpotService.js +856 -0
  286. package/services/keeperStateService.js +355 -0
  287. package/services/matchupImageService.js +591 -0
  288. package/services/notificationCacheService.js +407 -0
  289. package/services/pickemOracle.js +440 -0
  290. package/services/playerStatsService.js +389 -0
  291. package/services/portfolioService.js +555 -0
  292. package/services/promoService.js +757 -0
  293. package/services/promoTreasuryService.js +239 -0
  294. package/services/pushNotifications.js +353 -0
  295. package/services/redisService.js +422 -0
  296. package/services/referralEarningsService.js +728 -0
  297. package/services/s3Service.js +396 -0
  298. package/services/socialService.js +1202 -0
  299. package/services/survivorOracle.js +469 -0
  300. package/services/survivorSimulator.js +475 -0
  301. package/services/telegramNotifications.js +461 -0
  302. package/services/userProfileStatsService.js +1185 -0
  303. package/services/whatsNewService.js +388 -0
  304. package/utils/urlHelper.js +95 -0
@@ -0,0 +1,1185 @@
1
+ /**
2
+ * 📊 User Profile Stats Service
3
+ *
4
+ * High-performance service for fetching comprehensive user statistics
5
+ * including PNL from jackpot games AND sports betting games.
6
+ *
7
+ * Optimized with:
8
+ * - Single efficient SQL query joining all relevant tables
9
+ * - In-memory caching with TTL
10
+ * - Batch-friendly design for leaderboards
11
+ */
12
+
13
+ const { pool } = require('./db'); // Shared database pool
14
+
15
+ class UserProfileStatsService {
16
+ constructor() {
17
+ // Use shared pool from services/db.js
18
+ this.pool = pool;
19
+
20
+ // Simple in-memory cache with TTL (30 seconds)
21
+ this.cache = new Map();
22
+ this.CACHE_TTL = 30 * 1000; // 30 seconds
23
+
24
+ this.initializeTables();
25
+ }
26
+
27
+ async initializeTables() {
28
+ try {
29
+ // Create sports_betting_stats table for aggregated sports stats
30
+ await this.pool.query(`
31
+ CREATE TABLE IF NOT EXISTS sports_betting_stats (
32
+ wallet_address VARCHAR(100) PRIMARY KEY,
33
+ games_created INTEGER DEFAULT 0,
34
+ games_joined INTEGER DEFAULT 0,
35
+ games_won INTEGER DEFAULT 0,
36
+ games_lost INTEGER DEFAULT 0,
37
+ total_wagered_sol NUMERIC(20, 9) DEFAULT 0,
38
+ total_won_sol NUMERIC(20, 9) DEFAULT 0,
39
+ net_pnl_sol NUMERIC(20, 9) DEFAULT 0,
40
+ biggest_win_sol NUMERIC(20, 9) DEFAULT 0,
41
+ biggest_win_game_id VARCHAR(255),
42
+ current_win_streak INTEGER DEFAULT 0,
43
+ longest_win_streak INTEGER DEFAULT 0,
44
+ last_played_at TIMESTAMP,
45
+ created_at TIMESTAMP DEFAULT NOW(),
46
+ updated_at TIMESTAMP DEFAULT NOW()
47
+ );
48
+
49
+ CREATE INDEX IF NOT EXISTS idx_sports_stats_pnl ON sports_betting_stats(net_pnl_sol DESC);
50
+ CREATE INDEX IF NOT EXISTS idx_sports_stats_wagered ON sports_betting_stats(total_wagered_sol DESC);
51
+ CREATE INDEX IF NOT EXISTS idx_sports_stats_wins ON sports_betting_stats(games_won DESC);
52
+ `);
53
+
54
+ console.log('✅ User profile stats tables initialized');
55
+ } catch (error) {
56
+ console.error('❌ Failed to initialize user profile stats tables:', error.message);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Get comprehensive user profile stats (cached)
62
+ * @param {string} walletAddress - The user's wallet address
63
+ * @returns {Object} Complete stats object
64
+ */
65
+ async getUserProfileStats(walletAddress) {
66
+ // Check cache first
67
+ const cached = this.cache.get(walletAddress);
68
+ if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
69
+ return cached.data;
70
+ }
71
+
72
+ try {
73
+ const stats = await this._fetchUserStats(walletAddress);
74
+
75
+ // Cache the result
76
+ this.cache.set(walletAddress, {
77
+ data: stats,
78
+ timestamp: Date.now()
79
+ });
80
+
81
+ return stats;
82
+ } catch (error) {
83
+ console.error('Error fetching user profile stats:', error);
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Internal method to fetch all stats with optimized queries
90
+ */
91
+ async _fetchUserStats(walletAddress) {
92
+ const startTime = Date.now();
93
+
94
+ // Run queries in parallel for maximum performance
95
+ const [userInfo, jackpotStats, sportsStats, billiardsStats, connect4Stats, recentGames, dominantLeagues] = await Promise.all([
96
+ this._getUserInfo(walletAddress),
97
+ this._getJackpotStats(walletAddress),
98
+ this._getSportsStats(walletAddress),
99
+ this._getBilliardsStats(walletAddress),
100
+ this._getConnect4Stats(walletAddress),
101
+ this._getRecentGames(walletAddress, 10),
102
+ this._getDominantLeagues(walletAddress),
103
+ ]);
104
+
105
+ // Calculate combined stats (now includes billiards and connect4!)
106
+ const totalWagered = (jackpotStats?.totalWagered || 0) + (sportsStats?.totalWagered || 0) + (billiardsStats?.totalWagered || 0) + (connect4Stats?.totalWagered || 0);
107
+ const totalWon = (jackpotStats?.totalWon || 0) + (sportsStats?.totalWon || 0) + (billiardsStats?.totalWon || 0) + (connect4Stats?.totalWon || 0);
108
+ const netPNL = totalWon - totalWagered;
109
+ const totalGamesPlayed = (jackpotStats?.roundsPlayed || 0) + (sportsStats?.gamesPlayed || 0) + (billiardsStats?.roomsPlayed || 0) + (connect4Stats?.gamesPlayed || 0);
110
+ const totalGamesWon = (jackpotStats?.roundsWon || 0) + (sportsStats?.gamesWon || 0) + (billiardsStats?.roomsWon || 0) + (connect4Stats?.gamesWon || 0);
111
+ const winRate = totalGamesPlayed > 0 ? (totalGamesWon / totalGamesPlayed * 100) : 0;
112
+
113
+ const elapsed = Date.now() - startTime;
114
+ console.log(`📊 Fetched profile stats for ${walletAddress.slice(0, 8)}... in ${elapsed}ms (jackpot: ${!!jackpotStats}, sports: ${!!sportsStats}, billiards: ${!!billiardsStats}, connect4: ${!!connect4Stats})`);
115
+
116
+ return {
117
+ // User Info
118
+ walletAddress,
119
+ username: userInfo?.username || null,
120
+ avatar: userInfo?.avatar || null,
121
+ preferredCurrency: userInfo?.preferred_currency || 'USD',
122
+ userId: userInfo?.id || null,
123
+ memberSince: userInfo?.created_at || null,
124
+ telegramConnected: !!userInfo?.telegram_user_id, // Whether user has Telegram linked
125
+
126
+ // Combined Stats
127
+ summary: {
128
+ totalWagered: parseFloat(totalWagered.toFixed(4)),
129
+ totalWon: parseFloat(totalWon.toFixed(4)),
130
+ netPNL: parseFloat(netPNL.toFixed(4)),
131
+ totalGamesPlayed,
132
+ totalGamesWon,
133
+ winRate: parseFloat(winRate.toFixed(2)),
134
+ isProfitable: netPNL > 0,
135
+ },
136
+
137
+ // Jackpot Stats (Solpot-style games)
138
+ jackpot: jackpotStats ? {
139
+ roundsPlayed: jackpotStats.roundsPlayed,
140
+ roundsWon: jackpotStats.roundsWon,
141
+ totalWagered: parseFloat((jackpotStats.totalWagered || 0).toFixed(4)),
142
+ totalWon: parseFloat((jackpotStats.totalWon || 0).toFixed(4)),
143
+ netPNL: parseFloat((jackpotStats.netPNL || 0).toFixed(4)),
144
+ biggestWin: parseFloat((jackpotStats.biggestWin || 0).toFixed(4)),
145
+ biggestWinRound: jackpotStats.biggestWinRound,
146
+ winRate: parseFloat(((jackpotStats.roundsPlayed > 0 ? jackpotStats.roundsWon / jackpotStats.roundsPlayed : 0) * 100).toFixed(2)),
147
+ } : null,
148
+
149
+ // Sports Betting Stats
150
+ sports: sportsStats ? {
151
+ gamesCreated: sportsStats.gamesCreated,
152
+ gamesJoined: sportsStats.gamesJoined,
153
+ gamesPlayed: sportsStats.gamesPlayed,
154
+ gamesWon: sportsStats.gamesWon,
155
+ gamesLost: sportsStats.gamesLost,
156
+ gamesPending: sportsStats.gamesPending,
157
+ totalWagered: parseFloat((sportsStats.totalWagered || 0).toFixed(4)),
158
+ pendingWagered: parseFloat((sportsStats.pendingWagered || 0).toFixed(4)),
159
+ totalWon: parseFloat((sportsStats.totalWon || 0).toFixed(4)),
160
+ netPNL: parseFloat((sportsStats.netPNL || 0).toFixed(4)),
161
+ biggestWin: parseFloat((sportsStats.biggestWin || 0).toFixed(4)),
162
+ currentStreak: sportsStats.currentStreak,
163
+ longestStreak: sportsStats.longestStreak,
164
+ winRate: parseFloat(((sportsStats.gamesPlayed > 0 ? sportsStats.gamesWon / sportsStats.gamesPlayed : 0) * 100).toFixed(2)),
165
+ } : null,
166
+
167
+ // 🎱 Billiards/Pool Room Stats
168
+ billiards: billiardsStats ? {
169
+ roomsCreated: billiardsStats.roomsCreated,
170
+ roomsJoined: billiardsStats.roomsJoined,
171
+ roomsPlayed: billiardsStats.roomsPlayed,
172
+ roomsWon: billiardsStats.roomsWon,
173
+ roomsLost: billiardsStats.roomsLost,
174
+ roomsPending: billiardsStats.roomsPending,
175
+ totalWagered: parseFloat((billiardsStats.totalWagered || 0).toFixed(4)),
176
+ pendingWagered: parseFloat((billiardsStats.pendingWagered || 0).toFixed(4)),
177
+ totalWon: parseFloat((billiardsStats.totalWon || 0).toFixed(4)),
178
+ netPNL: parseFloat((billiardsStats.netPNL || 0).toFixed(4)),
179
+ biggestWin: parseFloat((billiardsStats.biggestWin || 0).toFixed(4)),
180
+ biggestWinRoomId: billiardsStats.biggestWinRoomId,
181
+ currentStreak: billiardsStats.currentStreak,
182
+ longestStreak: billiardsStats.longestStreak,
183
+ winRate: parseFloat((billiardsStats.winRate || 0).toFixed(2)),
184
+ } : null,
185
+
186
+ // 🔴 Connect4 Stats
187
+ connect4: connect4Stats ? {
188
+ gamesCreated: connect4Stats.gamesCreated,
189
+ gamesJoined: connect4Stats.gamesJoined,
190
+ gamesPlayed: connect4Stats.gamesPlayed,
191
+ gamesWon: connect4Stats.gamesWon,
192
+ gamesLost: connect4Stats.gamesLost,
193
+ gamesPending: connect4Stats.gamesPending,
194
+ totalWagered: parseFloat((connect4Stats.totalWagered || 0).toFixed(4)),
195
+ pendingWagered: parseFloat((connect4Stats.pendingWagered || 0).toFixed(4)),
196
+ totalWon: parseFloat((connect4Stats.totalWon || 0).toFixed(4)),
197
+ netPNL: parseFloat((connect4Stats.netPNL || 0).toFixed(4)),
198
+ biggestWin: parseFloat((connect4Stats.biggestWin || 0).toFixed(4)),
199
+ biggestWinGameId: connect4Stats.biggestWinGameId,
200
+ currentStreak: connect4Stats.currentStreak,
201
+ longestStreak: connect4Stats.longestStreak,
202
+ winRate: parseFloat((connect4Stats.winRate || 0).toFixed(2)),
203
+ } : null,
204
+
205
+ // Recent Games (for history display)
206
+ recentGames,
207
+
208
+ // Badges based on dominant leagues/games
209
+ badges: dominantLeagues,
210
+
211
+ // Metadata
212
+ _meta: {
213
+ fetchedAt: new Date().toISOString(),
214
+ queryTimeMs: elapsed,
215
+ }
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Get basic user info
221
+ */
222
+ async _getUserInfo(walletAddress) {
223
+ const result = await this.pool.query(
224
+ 'SELECT id, wallet_address, username, avatar, preferred_currency, created_at, telegram_user_id FROM users WHERE wallet_address = $1',
225
+ [walletAddress]
226
+ );
227
+ return result.rows[0] || null;
228
+ }
229
+
230
+ /**
231
+ * Get jackpot/solpot game stats
232
+ */
233
+ async _getJackpotStats(walletAddress) {
234
+ const result = await this.pool.query(
235
+ 'SELECT * FROM player_stats WHERE wallet_address = $1',
236
+ [walletAddress]
237
+ );
238
+
239
+ if (result.rows.length === 0) return null;
240
+
241
+ const stats = result.rows[0];
242
+ return {
243
+ totalWagered: parseFloat(stats.total_wagered || 0),
244
+ totalWon: parseFloat(stats.total_won || 0),
245
+ netPNL: parseFloat(stats.net_pnl || 0),
246
+ roundsPlayed: stats.rounds_played || 0,
247
+ roundsWon: stats.rounds_won || 0,
248
+ biggestWin: parseFloat(stats.biggest_win || 0),
249
+ biggestWinRound: stats.biggest_win_round,
250
+ lastPlayed: stats.last_played,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Get user's dominant leagues/games for badge display
256
+ * Includes sports leagues, connect4, and billiards
257
+ */
258
+ async _getDominantLeagues(walletAddress) {
259
+ try {
260
+ // Get sports league counts
261
+ const sportsResult = await this.pool.query(`
262
+ SELECT
263
+ g.sports_event->>'strLeague' as league,
264
+ COUNT(*) as game_count
265
+ FROM user_game_refs ugr
266
+ JOIN games g ON ugr.game_id = g.game_id
267
+ WHERE ugr.wallet_address = $1
268
+ AND g.game_type = 'sports'
269
+ AND g.sports_event->>'strLeague' IS NOT NULL
270
+ GROUP BY g.sports_event->>'strLeague'
271
+ ORDER BY game_count DESC
272
+ LIMIT 5
273
+ `, [walletAddress]);
274
+
275
+ // Get connect4 game count
276
+ const connect4Result = await this.pool.query(`
277
+ SELECT COUNT(*) as game_count
278
+ FROM user_game_refs ugr
279
+ JOIN games g ON ugr.game_id = g.game_id
280
+ WHERE ugr.wallet_address = $1
281
+ AND g.game_type = 'connect4'
282
+ `, [walletAddress]);
283
+
284
+ // Get billiards game count
285
+ const billiardsResult = await this.pool.query(`
286
+ SELECT COUNT(*) as game_count
287
+ FROM user_game_refs ugr
288
+ JOIN games g ON ugr.game_id = g.game_id
289
+ WHERE ugr.wallet_address = $1
290
+ AND g.game_type = 'billiards'
291
+ `, [walletAddress]);
292
+
293
+ // Map leagues to emojis and badges
294
+ const leagueEmojis = {
295
+ 'NFL': { emoji: '🏈', name: 'Football Fan' },
296
+ 'NBA': { emoji: '🏀', name: 'Hoops Lover' },
297
+ 'NHL': { emoji: '🏒', name: 'Hockey Head' },
298
+ 'MLB': { emoji: '⚾', name: 'Baseball Boss' },
299
+ 'English Premier League': { emoji: '⚽', name: 'Soccer Star' },
300
+ 'La Liga': { emoji: '⚽', name: 'La Liga Legend' },
301
+ 'Bundesliga': { emoji: '⚽', name: 'Bundesliga Beast' },
302
+ 'Serie A': { emoji: '⚽', name: 'Serie A Shark' },
303
+ 'Ligue 1': { emoji: '⚽', name: 'Ligue 1 Lion' },
304
+ 'MLS': { emoji: '⚽', name: 'MLS Maven' },
305
+ 'UFC': { emoji: '🥊', name: 'Fight Fan' },
306
+ 'Boxing': { emoji: '🥊', name: 'Boxing Baron' },
307
+ 'Tennis': { emoji: '🎾', name: 'Tennis Titan' },
308
+ 'Golf': { emoji: '⛳', name: 'Golf Guru' },
309
+ 'Formula 1': { emoji: '🏎️', name: 'F1 Fanatic' },
310
+ 'NASCAR': { emoji: '🏁', name: 'NASCAR Nut' },
311
+ 'connect4': { emoji: '🔴', name: 'Connect4 Pro' },
312
+ 'billiards': { emoji: '🎱', name: 'Pool Shark' },
313
+ };
314
+
315
+ const badges = sportsResult.rows.map(row => {
316
+ const leagueInfo = leagueEmojis[row.league] || { emoji: '🎮', name: row.league || 'Gamer' };
317
+ return {
318
+ league: row.league,
319
+ emoji: leagueInfo.emoji,
320
+ name: leagueInfo.name,
321
+ gamesPlayed: parseInt(row.game_count),
322
+ };
323
+ });
324
+
325
+ // Add Connect4 badge if user has played
326
+ const connect4Count = parseInt(connect4Result.rows[0]?.game_count || 0);
327
+ if (connect4Count > 0) {
328
+ badges.push({
329
+ league: 'connect4',
330
+ emoji: leagueEmojis.connect4.emoji,
331
+ name: leagueEmojis.connect4.name,
332
+ gamesPlayed: connect4Count,
333
+ });
334
+ }
335
+
336
+ // Add Billiards badge if user has played
337
+ const billiardsCount = parseInt(billiardsResult.rows[0]?.game_count || 0);
338
+ if (billiardsCount > 0) {
339
+ badges.push({
340
+ league: 'billiards',
341
+ emoji: leagueEmojis.billiards.emoji,
342
+ name: leagueEmojis.billiards.name,
343
+ gamesPlayed: billiardsCount,
344
+ });
345
+ }
346
+
347
+ // Sort by games played and limit to top 5
348
+ return badges.sort((a, b) => b.gamesPlayed - a.gamesPlayed).slice(0, 5);
349
+ } catch (error) {
350
+ console.error('Error getting dominant leagues:', error);
351
+ return [];
352
+ }
353
+ }
354
+
355
+ /**
356
+ * 🎱 Get billiards/pool room stats
357
+ *
358
+ * Billiards uses game_mode = 0 (Manual mode)
359
+ * Winners are identified by status = 'won' in user_game_refs
360
+ * PNL is calculated from amount_claimed - buy_in
361
+ */
362
+ async _getBilliardsStats(walletAddress) {
363
+ try {
364
+ // Get all billiards games the user participated in
365
+ // Note: winner info is stored in sports_event->'winner'->>'walletAddress' (from billiards settlement)
366
+ const result = await this.pool.query(`
367
+ SELECT
368
+ ugr.game_id,
369
+ ugr.role,
370
+ ugr.status,
371
+ ugr.claimed_at,
372
+ ugr.amount_claimed,
373
+ ugr.joined_at,
374
+ g.buy_in,
375
+ g.is_resolved,
376
+ g.created_by,
377
+ g.title,
378
+ g.sports_event->'winner'->>'walletAddress' as winner_wallet
379
+ FROM user_game_refs ugr
380
+ JOIN games g ON ugr.game_id = g.game_id
381
+ WHERE ugr.wallet_address = $1
382
+ AND g.game_type = 'billiards'
383
+ ORDER BY ugr.joined_at ASC
384
+ `, [walletAddress]);
385
+
386
+ if (result.rows.length === 0) return null;
387
+
388
+ let roomsCreated = 0;
389
+ let roomsJoined = 0;
390
+ let roomsWon = 0;
391
+ let roomsLost = 0;
392
+ let roomsPending = 0;
393
+ let totalWagered = 0; // Only resolved games
394
+ let pendingWagered = 0; // Pending games (not yet counted in P&L)
395
+ let totalWon = 0;
396
+ let biggestWin = 0;
397
+ let biggestWinRoomId = null;
398
+ let currentStreak = 0;
399
+ let longestStreak = 0;
400
+ let lastResult = null; // 'win' or 'loss'
401
+
402
+ for (const game of result.rows) {
403
+ const buyIn = parseFloat(game.buy_in) || 0;
404
+
405
+ // Track if user created this room
406
+ if (game.created_by === walletAddress) {
407
+ roomsCreated++;
408
+ } else {
409
+ roomsJoined++;
410
+ }
411
+
412
+ // Check if game is resolved
413
+ if (!game.is_resolved) {
414
+ // Game still pending - DON'T count towards P&L yet
415
+ roomsPending++;
416
+ pendingWagered += buyIn;
417
+ continue;
418
+ }
419
+
420
+ // Only count wagered amount for RESOLVED games
421
+ totalWagered += buyIn;
422
+
423
+ // Determine if user won using status field or winner_wallet
424
+ const userWon = game.status === 'won' || game.winner_wallet === walletAddress;
425
+
426
+ if (userWon) {
427
+ roomsWon++;
428
+
429
+ // Calculate winnings from amount_claimed if available
430
+ const amountClaimed = parseFloat(game.amount_claimed) || 0;
431
+ const winAmount = amountClaimed > 0 ? amountClaimed : 0;
432
+
433
+ totalWon += winAmount;
434
+ if (winAmount > biggestWin) {
435
+ biggestWin = winAmount;
436
+ biggestWinRoomId = game.game_id;
437
+ }
438
+
439
+ // Streak tracking
440
+ if (lastResult === 'win') {
441
+ currentStreak++;
442
+ } else {
443
+ currentStreak = 1;
444
+ }
445
+ lastResult = 'win';
446
+ if (currentStreak > longestStreak) {
447
+ longestStreak = currentStreak;
448
+ }
449
+ } else if (game.is_resolved) {
450
+ // Game resolved but user didn't win = loss
451
+ roomsLost++;
452
+
453
+ // Streak tracking
454
+ if (lastResult === 'loss') {
455
+ currentStreak--;
456
+ } else {
457
+ currentStreak = -1;
458
+ }
459
+ lastResult = 'loss';
460
+ }
461
+ }
462
+
463
+ const roomsPlayed = roomsWon + roomsLost;
464
+
465
+ return {
466
+ roomsCreated,
467
+ roomsJoined,
468
+ roomsPlayed,
469
+ roomsWon,
470
+ roomsLost,
471
+ roomsPending,
472
+ totalWagered, // Only from resolved games
473
+ pendingWagered, // Amount at stake in pending games
474
+ totalWon,
475
+ netPNL: totalWon - totalWagered,
476
+ biggestWin,
477
+ biggestWinRoomId,
478
+ currentStreak,
479
+ longestStreak,
480
+ winRate: roomsPlayed > 0 ? (roomsWon / roomsPlayed * 100) : 0,
481
+ };
482
+ } catch (error) {
483
+ console.error('🎱 Error getting billiards stats:', error);
484
+ return null;
485
+ }
486
+ }
487
+
488
+ /**
489
+ * 🔴 Get Connect4 game stats
490
+ *
491
+ * Connect4 uses game_type = 'connect4'
492
+ * Winners are determined by connect4_winner field ('red' or 'yellow')
493
+ * Red = creator (home), Yellow = joiner (away)
494
+ */
495
+ async _getConnect4Stats(walletAddress) {
496
+ try {
497
+ // Get all connect4 games the user participated in
498
+ const result = await this.pool.query(`
499
+ SELECT
500
+ ugr.game_id,
501
+ ugr.role,
502
+ ugr.team_choice,
503
+ ugr.status,
504
+ ugr.claimed_at,
505
+ ugr.amount_claimed,
506
+ ugr.joined_at,
507
+ g.buy_in,
508
+ g.is_resolved,
509
+ g.game_status,
510
+ g.created_by,
511
+ g.title,
512
+ g.connect4_winner
513
+ FROM user_game_refs ugr
514
+ JOIN games g ON ugr.game_id = g.game_id
515
+ WHERE ugr.wallet_address = $1
516
+ AND g.game_type = 'connect4'
517
+ ORDER BY ugr.joined_at ASC
518
+ `, [walletAddress]);
519
+
520
+ if (result.rows.length === 0) return null;
521
+
522
+ let gamesCreated = 0;
523
+ let gamesJoined = 0;
524
+ let gamesWon = 0;
525
+ let gamesLost = 0;
526
+ let gamesPending = 0;
527
+ let totalWagered = 0; // Only resolved games
528
+ let pendingWagered = 0; // Pending games (not yet counted in P&L)
529
+ let totalWon = 0;
530
+ let biggestWin = 0;
531
+ let biggestWinGameId = null;
532
+ let currentStreak = 0;
533
+ let longestStreak = 0;
534
+ let lastResult = null; // 'win' or 'loss'
535
+
536
+ for (const game of result.rows) {
537
+ const buyIn = parseFloat(game.buy_in) || 0;
538
+
539
+ // Track if user created this game
540
+ if (game.created_by === walletAddress) {
541
+ gamesCreated++;
542
+ } else {
543
+ gamesJoined++;
544
+ }
545
+
546
+ // Check if game is a valid completed game with a winner (not cancelled, not draw)
547
+ // Only count games where game_status = 'completed' and connect4_winner is 'home' or 'away'
548
+ const isValidCompletedGame = game.game_status === 'completed' &&
549
+ game.connect4_winner &&
550
+ (game.connect4_winner === 'home' || game.connect4_winner === 'away');
551
+
552
+ if (!isValidCompletedGame) {
553
+ // Game is pending, cancelled, or a draw - DON'T count towards W/L stats
554
+ gamesPending++;
555
+ pendingWagered += buyIn;
556
+ continue;
557
+ }
558
+
559
+ // Only count wagered amount for RESOLVED games
560
+ totalWagered += buyIn;
561
+
562
+ // Determine if user won
563
+ // Red = creator plays as 'red', Yellow = joiner plays as 'yellow'
564
+ // team_choice should be 'red' or 'yellow'
565
+ const userWon = game.team_choice === game.connect4_winner;
566
+
567
+ if (userWon) {
568
+ gamesWon++;
569
+
570
+ // Calculate winnings from amount_claimed if available
571
+ const amountClaimed = parseFloat(game.amount_claimed) || 0;
572
+ const winAmount = amountClaimed > 0 ? amountClaimed : 0;
573
+
574
+ totalWon += winAmount;
575
+ if (winAmount > biggestWin) {
576
+ biggestWin = winAmount;
577
+ biggestWinGameId = game.game_id;
578
+ }
579
+
580
+ // Streak tracking
581
+ if (lastResult === 'win') {
582
+ currentStreak++;
583
+ } else {
584
+ currentStreak = 1;
585
+ }
586
+ lastResult = 'win';
587
+ if (currentStreak > longestStreak) {
588
+ longestStreak = currentStreak;
589
+ }
590
+ } else {
591
+ // Game resolved but user didn't win = loss
592
+ gamesLost++;
593
+
594
+ // Streak tracking
595
+ if (lastResult === 'loss') {
596
+ currentStreak--;
597
+ } else {
598
+ currentStreak = -1;
599
+ }
600
+ lastResult = 'loss';
601
+ }
602
+ }
603
+
604
+ const gamesPlayed = gamesWon + gamesLost;
605
+
606
+ return {
607
+ gamesCreated,
608
+ gamesJoined,
609
+ gamesPlayed,
610
+ gamesWon,
611
+ gamesLost,
612
+ gamesPending,
613
+ totalWagered, // Only from resolved games
614
+ pendingWagered, // Amount at stake in pending games
615
+ totalWon,
616
+ netPNL: totalWon - totalWagered,
617
+ biggestWin,
618
+ biggestWinGameId,
619
+ currentStreak,
620
+ longestStreak,
621
+ winRate: gamesPlayed > 0 ? (gamesWon / gamesPlayed * 100) : 0,
622
+ };
623
+ } catch (error) {
624
+ console.error('🔴 Error getting Connect4 stats:', error);
625
+ return null;
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Get sports betting stats (calculated from games tables)
631
+ */
632
+ async _getSportsStats(walletAddress) {
633
+ // Get all sports games the user participated in
634
+ const result = await this.pool.query(`
635
+ SELECT
636
+ ugr.game_id,
637
+ ugr.role,
638
+ ugr.team_choice,
639
+ ugr.claimed_at,
640
+ ugr.amount_claimed,
641
+ ugr.joined_at,
642
+ g.buy_in,
643
+ g.is_resolved,
644
+ g.is_locked,
645
+ g.game_mode,
646
+ g.sports_event,
647
+ g.home_team_players,
648
+ g.away_team_players,
649
+ g.created_by
650
+ FROM user_game_refs ugr
651
+ JOIN games g ON ugr.game_id = g.game_id
652
+ WHERE ugr.wallet_address = $1
653
+ AND g.game_mode = 4
654
+ ORDER BY ugr.joined_at ASC
655
+ `, [walletAddress]);
656
+
657
+ if (result.rows.length === 0) return null;
658
+
659
+ let gamesCreated = 0;
660
+ let gamesJoined = 0;
661
+ let gamesWon = 0;
662
+ let gamesLost = 0;
663
+ let gamesPending = 0;
664
+ let totalWagered = 0; // Only resolved games
665
+ let pendingWagered = 0; // Pending games (not yet counted in P&L)
666
+ let totalWon = 0;
667
+ let biggestWin = 0;
668
+ let currentStreak = 0;
669
+ let longestStreak = 0;
670
+ let lastResult = null; // 'win' or 'loss'
671
+
672
+ for (const game of result.rows) {
673
+ const buyIn = parseFloat(game.buy_in) || 0;
674
+
675
+ // Track if user created this game
676
+ if (game.created_by === walletAddress) {
677
+ gamesCreated++;
678
+ } else {
679
+ gamesJoined++;
680
+ }
681
+
682
+ if (!game.is_resolved) {
683
+ // Game still pending - DON'T count towards P&L yet
684
+ gamesPending++;
685
+ pendingWagered += buyIn;
686
+ continue;
687
+ }
688
+
689
+ // Check if both teams have players (if not, it's a refund scenario)
690
+ const homeCount = game.home_team_players?.length || 0;
691
+ const awayCount = game.away_team_players?.length || 0;
692
+
693
+ // If either side has no players, this is a refund - don't count in win/loss stats
694
+ if (homeCount === 0 || awayCount === 0) {
695
+ console.log(`📊 Skipping refund game ${game.game_id} - no competition (home: ${homeCount}, away: ${awayCount})`);
696
+ continue;
697
+ }
698
+
699
+ // Only count wagered amount for RESOLVED games with actual competition
700
+ totalWagered += buyIn;
701
+
702
+ // Game is resolved - check if user won
703
+ const finalScore = game.sports_event?.finalScore;
704
+ if (finalScore && game.team_choice) {
705
+ const userWon = game.team_choice === finalScore.winner;
706
+
707
+ if (userWon) {
708
+ gamesWon++;
709
+
710
+ // Calculate winnings
711
+ // Total pot = buy_in * total_players
712
+ // Winner share = pot * (1 - fee) / winners_count
713
+ const totalPlayers = homeCount + awayCount;
714
+ const totalPot = buyIn * totalPlayers;
715
+ const feePercent = 0.12; // 10% operator + 2% oracle
716
+ const winnersCount = finalScore.winner === 'home' ? homeCount : awayCount;
717
+ const winShare = winnersCount > 0 ? (totalPot * (1 - feePercent)) / winnersCount : 0;
718
+
719
+ totalWon += winShare;
720
+ if (winShare > biggestWin) {
721
+ biggestWin = winShare;
722
+ }
723
+
724
+ // Streak tracking
725
+ if (lastResult === 'win') {
726
+ currentStreak++;
727
+ } else {
728
+ currentStreak = 1;
729
+ }
730
+ lastResult = 'win';
731
+ if (currentStreak > longestStreak) {
732
+ longestStreak = currentStreak;
733
+ }
734
+ } else {
735
+ gamesLost++;
736
+ // Lost - streak broken
737
+ if (lastResult === 'loss') {
738
+ currentStreak--;
739
+ } else {
740
+ currentStreak = -1;
741
+ }
742
+ lastResult = 'loss';
743
+ }
744
+ }
745
+ }
746
+
747
+ return {
748
+ gamesCreated,
749
+ gamesJoined,
750
+ gamesPlayed: gamesWon + gamesLost,
751
+ gamesWon,
752
+ gamesLost,
753
+ gamesPending,
754
+ totalWagered, // Only from resolved games
755
+ pendingWagered, // Amount at stake in pending games
756
+ totalWon,
757
+ netPNL: totalWon - totalWagered,
758
+ biggestWin,
759
+ currentStreak,
760
+ longestStreak,
761
+ };
762
+ }
763
+
764
+ /**
765
+ * Get recent games for display
766
+ * Handles sports games, billiards/pool rooms, Connect4, AND Jackpot rounds
767
+ * Supports legacy, hybrid, and pari-mutuel betting modes
768
+ */
769
+ async _getRecentGames(walletAddress, limit = 10) {
770
+ // Fetch regular games and jackpot entries in parallel
771
+ const [gamesResult, jackpotResult] = await Promise.all([
772
+ // Note: For billiards, winner is stored in sports_event->'winner'->>'walletAddress'
773
+ this.pool.query(`
774
+ SELECT
775
+ ugr.game_id,
776
+ ugr.team_choice,
777
+ ugr.status,
778
+ ugr.joined_at,
779
+ ugr.claimed_at,
780
+ ugr.amount_claimed,
781
+ g.title,
782
+ g.buy_in,
783
+ g.is_resolved,
784
+ g.sports_event,
785
+ g.home_team_players,
786
+ g.away_team_players,
787
+ g.game_type,
788
+ g.sports_event->'winner'->>'walletAddress' as billiards_winner_wallet,
789
+ g.connect4_winner,
790
+ g.total_pool,
791
+ g.home_pool,
792
+ g.away_pool,
793
+ g.draw_pool,
794
+ g.player_amounts
795
+ FROM user_game_refs ugr
796
+ JOIN games g ON ugr.game_id = g.game_id
797
+ WHERE ugr.wallet_address = $1
798
+ ORDER BY ugr.joined_at DESC
799
+ LIMIT $2
800
+ `, [walletAddress, limit]),
801
+ // Jackpot entries joined with round results
802
+ this.pool.query(`
803
+ SELECT
804
+ je.round_id,
805
+ je.amount as entry_amount,
806
+ je.created_at as entered_at,
807
+ jr.winner,
808
+ jr.win_amount,
809
+ jr.total_pot,
810
+ jr.entry_count,
811
+ jr.timestamp as resolved_at
812
+ FROM jackpot_entries je
813
+ LEFT JOIN jackpot_rounds jr ON je.round_id = jr.round_id
814
+ WHERE je.wallet_address = $1
815
+ ORDER BY je.created_at DESC
816
+ LIMIT $2
817
+ `, [walletAddress, limit]),
818
+ ]);
819
+
820
+ const result = gamesResult;
821
+
822
+ // Build jackpot recent games
823
+ const jackpotGames = jackpotResult.rows.map(entry => {
824
+ const buyInLamports = parseInt(entry.entry_amount) || 0;
825
+ const buyInSOL = buyInLamports / 1e9;
826
+ const isResolved = !!entry.resolved_at;
827
+ const userWon = isResolved && entry.winner === walletAddress;
828
+
829
+ let resultStatus = 'pending';
830
+ let pnl = 0;
831
+
832
+ if (isResolved) {
833
+ if (userWon) {
834
+ resultStatus = 'won';
835
+ const winAmountSOL = (parseInt(entry.win_amount) || 0) / 1e9;
836
+ pnl = winAmountSOL - buyInSOL;
837
+ } else {
838
+ resultStatus = 'lost';
839
+ pnl = -buyInSOL;
840
+ }
841
+ }
842
+
843
+ return {
844
+ gameId: `jackpot_${entry.round_id}`,
845
+ title: `Jackpot Round #${entry.round_id}`,
846
+ gameType: 'jackpot',
847
+ teamChoice: null,
848
+ buyIn: parseFloat(buyInSOL.toFixed(4)),
849
+ result: resultStatus,
850
+ pnl: parseFloat(pnl.toFixed(4)),
851
+ finalScore: null,
852
+ playedAt: entry.entered_at,
853
+ claimed: isResolved,
854
+ };
855
+ });
856
+
857
+ // Build regular recent games
858
+ const regularGames = result.rows.map(game => {
859
+ const defaultBuyIn = parseFloat(game.buy_in) || 0;
860
+ const finalScore = game.sports_event?.finalScore;
861
+ const isBilliards = game.game_type === 'billiards';
862
+ const isConnect4 = game.game_type === 'connect4';
863
+
864
+ // Determine user's actual bet amount (pari-mutuel vs legacy)
865
+ const playerAmounts = game.player_amounts || {};
866
+ const userBetAmount = playerAmounts[walletAddress] !== undefined
867
+ ? parseFloat(playerAmounts[walletAddress])
868
+ : defaultBuyIn;
869
+
870
+ let resultStatus = 'pending';
871
+ let pnl = 0;
872
+
873
+ // 🎱 Handle billiards games
874
+ if (isBilliards) {
875
+ if (game.is_resolved) {
876
+ // Check if user won via status field or billiards_winner_wallet (from sports_event JSON)
877
+ const userWon = game.status === 'won' || game.billiards_winner_wallet === walletAddress;
878
+
879
+ if (userWon) {
880
+ resultStatus = 'won';
881
+ const amountClaimed = parseFloat(game.amount_claimed) || 0;
882
+ pnl = amountClaimed > 0 ? amountClaimed - userBetAmount : 0;
883
+ } else {
884
+ resultStatus = 'lost';
885
+ pnl = -userBetAmount;
886
+ }
887
+ }
888
+ // else remains 'pending'
889
+ }
890
+ // 🎮 Handle Connect4 games
891
+ else if (isConnect4) {
892
+ if (game.is_resolved && game.connect4_winner) {
893
+ const userWon = game.team_choice === game.connect4_winner;
894
+
895
+ if (userWon) {
896
+ resultStatus = 'won';
897
+ const amountClaimed = parseFloat(game.amount_claimed) || 0;
898
+ pnl = amountClaimed > 0 ? amountClaimed - userBetAmount : 0;
899
+ } else {
900
+ resultStatus = 'lost';
901
+ pnl = -userBetAmount;
902
+ }
903
+ }
904
+ // else remains 'pending'
905
+ }
906
+ // 🏀 Handle sports games (supports legacy, hybrid, pari-mutuel)
907
+ else if (game.is_resolved && finalScore && game.team_choice) {
908
+ const homeCount = game.home_team_players?.length || 0;
909
+ const awayCount = game.away_team_players?.length || 0;
910
+
911
+ // Check for refund scenario (no competition)
912
+ if (homeCount === 0 || awayCount === 0) {
913
+ resultStatus = 'refunded';
914
+ pnl = 0; // No loss, refunded
915
+ } else {
916
+ const userWon = game.team_choice === finalScore.winner;
917
+
918
+ if (userWon) {
919
+ resultStatus = 'won';
920
+
921
+ // Check if this is a pari-mutuel game (has player_amounts with entries)
922
+ const isPariMutuel = Object.keys(playerAmounts).length > 0;
923
+
924
+ if (isPariMutuel) {
925
+ // Pari-mutuel: proportional payout based on user's share of winning pool
926
+ const totalPool = parseFloat(game.total_pool) || 0;
927
+ const homePool = parseFloat(game.home_pool) || 0;
928
+ const awayPool = parseFloat(game.away_pool) || 0;
929
+ const drawPool = parseFloat(game.draw_pool) || 0;
930
+
931
+ const winningPool = finalScore.winner === 'home' ? homePool
932
+ : finalScore.winner === 'away' ? awayPool
933
+ : drawPool;
934
+
935
+ const feePercent = 0.06; // 6% fee (5% platform + 1% oracle)
936
+ const netPool = totalPool * (1 - feePercent);
937
+
938
+ // User's share = (userBet / winningPool) * netPool
939
+ const userShare = winningPool > 0 ? (userBetAmount / winningPool) * netPool : 0;
940
+ pnl = userShare - userBetAmount;
941
+ } else {
942
+ // Legacy: equal split among winners
943
+ const totalPlayers = homeCount + awayCount;
944
+ const totalPot = defaultBuyIn * totalPlayers;
945
+ const feePercent = 0.06; // 6% fee
946
+ const winnersCount = finalScore.winner === 'home' ? homeCount : awayCount;
947
+ const winShare = winnersCount > 0 ? (totalPot * (1 - feePercent)) / winnersCount : 0;
948
+ pnl = winShare - defaultBuyIn;
949
+ }
950
+ } else {
951
+ resultStatus = 'lost';
952
+ pnl = -userBetAmount;
953
+ }
954
+ }
955
+ }
956
+
957
+ // For pending games, show event date. For resolved, show when user joined.
958
+ const eventDate = game.sports_event?.strTimestamp;
959
+ const displayDate = resultStatus === 'pending' && eventDate
960
+ ? new Date(eventDate + 'Z') // Event date for pending
961
+ : game.joined_at; // Joined date for resolved
962
+
963
+ return {
964
+ gameId: game.game_id,
965
+ title: game.title,
966
+ gameType: game.game_type || 'sports', // Include game type for display
967
+ teamChoice: game.team_choice,
968
+ buyIn: parseFloat(userBetAmount.toFixed(4)), // Show user's actual bet, not default
969
+ result: resultStatus,
970
+ pnl: parseFloat(pnl.toFixed(4)),
971
+ finalScore: finalScore ? {
972
+ winner: finalScore.winner,
973
+ homeScore: finalScore.homeScore,
974
+ awayScore: finalScore.awayScore,
975
+ } : null,
976
+ playedAt: displayDate,
977
+ claimed: !!game.claimed_at,
978
+ };
979
+ });
980
+
981
+ // Merge regular games + jackpot games, sort by date desc, limit
982
+ const allGames = [...regularGames, ...jackpotGames]
983
+ .sort((a, b) => new Date(b.playedAt).getTime() - new Date(a.playedAt).getTime())
984
+ .slice(0, limit);
985
+
986
+ return allGames;
987
+ }
988
+
989
+ /**
990
+ * Record a sports betting win (called when game is resolved)
991
+ */
992
+ async recordSportsBetResult({ walletAddress, gameId, buyIn, won, winAmount }) {
993
+ try {
994
+ const pnlChange = won ? (winAmount - buyIn) : -buyIn;
995
+
996
+ await this.pool.query(`
997
+ INSERT INTO sports_betting_stats (
998
+ wallet_address,
999
+ games_joined,
1000
+ games_won,
1001
+ games_lost,
1002
+ total_wagered_sol,
1003
+ total_won_sol,
1004
+ net_pnl_sol,
1005
+ biggest_win_sol,
1006
+ biggest_win_game_id,
1007
+ current_win_streak,
1008
+ longest_win_streak,
1009
+ last_played_at
1010
+ ) VALUES ($1, 1, $2, $3, $4, $5, $6, $7, $8, $9, $9, NOW())
1011
+ ON CONFLICT (wallet_address) DO UPDATE SET
1012
+ games_joined = sports_betting_stats.games_joined + 1,
1013
+ games_won = sports_betting_stats.games_won + $2,
1014
+ games_lost = sports_betting_stats.games_lost + $3,
1015
+ total_wagered_sol = sports_betting_stats.total_wagered_sol + $4,
1016
+ total_won_sol = sports_betting_stats.total_won_sol + $5,
1017
+ net_pnl_sol = sports_betting_stats.net_pnl_sol + $6,
1018
+ biggest_win_sol = GREATEST(sports_betting_stats.biggest_win_sol, $7),
1019
+ biggest_win_game_id = CASE
1020
+ WHEN $7 > sports_betting_stats.biggest_win_sol THEN $8
1021
+ ELSE sports_betting_stats.biggest_win_game_id
1022
+ END,
1023
+ current_win_streak = CASE
1024
+ WHEN $2 = 1 THEN sports_betting_stats.current_win_streak + 1
1025
+ ELSE 0
1026
+ END,
1027
+ longest_win_streak = GREATEST(
1028
+ sports_betting_stats.longest_win_streak,
1029
+ CASE WHEN $2 = 1 THEN sports_betting_stats.current_win_streak + 1 ELSE 0 END
1030
+ ),
1031
+ last_played_at = NOW(),
1032
+ updated_at = NOW()
1033
+ `, [
1034
+ walletAddress,
1035
+ won ? 1 : 0,
1036
+ won ? 0 : 1,
1037
+ buyIn,
1038
+ won ? winAmount : 0,
1039
+ pnlChange,
1040
+ won ? winAmount : 0,
1041
+ won ? gameId : null,
1042
+ won ? 1 : 0,
1043
+ ]);
1044
+
1045
+ // Invalidate cache
1046
+ this.cache.delete(walletAddress);
1047
+
1048
+ console.log(`📊 Recorded sports bet result: ${walletAddress.slice(0, 8)}... ${won ? 'WON' : 'LOST'} ◎${buyIn}`);
1049
+ } catch (error) {
1050
+ console.error('Error recording sports bet result:', error);
1051
+ }
1052
+ }
1053
+
1054
+ /**
1055
+ * Get leaderboard with combined stats
1056
+ */
1057
+ async getLeaderboard(limit = 20, sortBy = 'pnl') {
1058
+ try {
1059
+ // Get all users with any stats
1060
+ const result = await this.pool.query(`
1061
+ SELECT DISTINCT wallet_address FROM (
1062
+ SELECT wallet_address FROM player_stats
1063
+ UNION
1064
+ SELECT wallet_address FROM user_game_refs
1065
+ ) combined
1066
+ LIMIT 100
1067
+ `);
1068
+
1069
+ const wallets = result.rows.map(r => r.wallet_address);
1070
+
1071
+ // Fetch stats for all users in parallel
1072
+ const statsPromises = wallets.map(wallet => this.getUserProfileStats(wallet));
1073
+ const allStats = await Promise.all(statsPromises);
1074
+
1075
+ // Sort based on criteria
1076
+ const sorted = allStats.sort((a, b) => {
1077
+ if (sortBy === 'pnl') {
1078
+ return b.summary.netPNL - a.summary.netPNL;
1079
+ } else if (sortBy === 'wagered') {
1080
+ return b.summary.totalWagered - a.summary.totalWagered;
1081
+ } else if (sortBy === 'wins') {
1082
+ return b.summary.totalGamesWon - a.summary.totalGamesWon;
1083
+ } else if (sortBy === 'winrate') {
1084
+ return b.summary.winRate - a.summary.winRate;
1085
+ }
1086
+ return 0;
1087
+ });
1088
+
1089
+ return sorted.slice(0, limit).map((stats, index) => ({
1090
+ rank: index + 1,
1091
+ walletAddress: stats.walletAddress,
1092
+ username: stats.username,
1093
+ avatar: stats.avatar,
1094
+ netPNL: stats.summary.netPNL,
1095
+ totalWagered: stats.summary.totalWagered,
1096
+ totalGamesPlayed: stats.summary.totalGamesPlayed,
1097
+ totalGamesWon: stats.summary.totalGamesWon,
1098
+ winRate: stats.summary.winRate,
1099
+ }));
1100
+ } catch (error) {
1101
+ console.error('Error fetching leaderboard:', error);
1102
+ return [];
1103
+ }
1104
+ }
1105
+
1106
+ /**
1107
+ * Clear cache for a specific wallet (call after game results)
1108
+ */
1109
+ invalidateCache(walletAddress) {
1110
+ this.cache.delete(walletAddress);
1111
+ }
1112
+
1113
+ /**
1114
+ * Clear all cache
1115
+ */
1116
+ clearCache() {
1117
+ this.cache.clear();
1118
+ }
1119
+
1120
+ /**
1121
+ * Get a user's friends list by wallet address (public)
1122
+ * Returns friends with their basic stats
1123
+ */
1124
+ async getUserFriends(walletAddress, limit = 20) {
1125
+ try {
1126
+ const result = await this.pool.query(`
1127
+ SELECT
1128
+ u2.wallet_address,
1129
+ u2.username,
1130
+ u2.avatar,
1131
+ ur.created_at as friends_since
1132
+ FROM users u1
1133
+ JOIN user_relationships ur ON u1.id = ur.user_id
1134
+ JOIN users u2 ON ur.target_user_id = u2.id
1135
+ WHERE u1.wallet_address = $1
1136
+ AND ur.relationship_type = 'friend'
1137
+ ORDER BY ur.created_at DESC
1138
+ LIMIT $2
1139
+ `, [walletAddress, limit]);
1140
+
1141
+ return result.rows.map(row => ({
1142
+ walletAddress: row.wallet_address,
1143
+ username: row.username,
1144
+ avatar: row.avatar,
1145
+ friendsSince: row.friends_since,
1146
+ }));
1147
+ } catch (error) {
1148
+ console.error('Error getting user friends:', error);
1149
+ return [];
1150
+ }
1151
+ }
1152
+
1153
+ /**
1154
+ * Get mutual friends between two users
1155
+ */
1156
+ async getMutualFriends(walletAddress1, walletAddress2) {
1157
+ try {
1158
+ const result = await this.pool.query(`
1159
+ SELECT DISTINCT
1160
+ u3.wallet_address,
1161
+ u3.username,
1162
+ u3.avatar
1163
+ FROM users u1
1164
+ JOIN user_relationships ur1 ON u1.id = ur1.user_id AND ur1.relationship_type = 'friend'
1165
+ JOIN users u2 ON u2.wallet_address = $2
1166
+ JOIN user_relationships ur2 ON u2.id = ur2.user_id AND ur2.relationship_type = 'friend'
1167
+ JOIN users u3 ON ur1.target_user_id = u3.id AND ur2.target_user_id = u3.id
1168
+ WHERE u1.wallet_address = $1
1169
+ LIMIT 10
1170
+ `, [walletAddress1, walletAddress2]);
1171
+
1172
+ return result.rows.map(row => ({
1173
+ walletAddress: row.wallet_address,
1174
+ username: row.username,
1175
+ avatar: row.avatar,
1176
+ }));
1177
+ } catch (error) {
1178
+ console.error('Error getting mutual friends:', error);
1179
+ return [];
1180
+ }
1181
+ }
1182
+ }
1183
+
1184
+ module.exports = UserProfileStatsService;
1185
+