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,554 @@
1
+ /**
2
+ * Pick'em Controller
3
+ * Business logic for UFC Pick'em pools
4
+ * Pattern mirrors survivorController.js
5
+ */
6
+
7
+ const { pool } = require('../services/db');
8
+
9
+ const LAMPORTS_PER_SOL = 1_000_000_000;
10
+ const FEE_PERCENT = 6; // 5% operator + 1% oracle
11
+
12
+ // ========== HELPERS ==========
13
+
14
+ function formatPool(row) {
15
+ if (!row) return null;
16
+ const buyInSol = Number(row.buy_in_lamports) / LAMPORTS_PER_SOL;
17
+ const totalEntries = Number(row.total_entries || 0);
18
+ const totalPotLamports = totalEntries * Number(row.buy_in_lamports);
19
+ const netPotLamports = Math.floor(totalPotLamports * (1 - FEE_PERCENT / 100));
20
+ return {
21
+ id: row.id,
22
+ name: row.name,
23
+ espnEventId: row.espn_event_id,
24
+ eventDate: row.event_date,
25
+ buyInLamports: Number(row.buy_in_lamports),
26
+ buyInSol,
27
+ lockTime: row.lock_time,
28
+ status: row.status,
29
+ solanaGameId: row.solana_game_id,
30
+ solanaGameAddress: row.solana_game_address,
31
+ totalEntries,
32
+ totalPotLamports,
33
+ totalPotSol: totalPotLamports / LAMPORTS_PER_SOL,
34
+ netPotLamports,
35
+ netPotSol: netPotLamports / LAMPORTS_PER_SOL,
36
+ createdBy: row.created_by,
37
+ createdAt: row.created_at,
38
+ updatedAt: row.updated_at,
39
+ };
40
+ }
41
+
42
+ function formatFight(row) {
43
+ if (!row) return null;
44
+ return {
45
+ id: row.id,
46
+ poolId: row.pool_id,
47
+ espnCompetitionId: row.espn_competition_id,
48
+ fightOrder: row.fight_order,
49
+ fighterA: {
50
+ name: row.fighter_a_name,
51
+ headshot: row.fighter_a_headshot,
52
+ country: row.fighter_a_country,
53
+ record: row.fighter_a_record,
54
+ },
55
+ fighterB: {
56
+ name: row.fighter_b_name,
57
+ headshot: row.fighter_b_headshot,
58
+ country: row.fighter_b_country,
59
+ record: row.fighter_b_record,
60
+ },
61
+ weightClass: row.weight_class,
62
+ winner: row.winner,
63
+ method: row.method,
64
+ status: row.status,
65
+ };
66
+ }
67
+
68
+ function formatEntry(row) {
69
+ if (!row) return null;
70
+ return {
71
+ id: row.id,
72
+ poolId: row.pool_id,
73
+ userId: row.user_id,
74
+ walletAddress: row.wallet_address,
75
+ entryTxSignature: row.entry_tx_signature,
76
+ score: row.score,
77
+ rank: row.rank,
78
+ username: row.username || null,
79
+ avatar: row.avatar || null,
80
+ createdAt: row.created_at,
81
+ };
82
+ }
83
+
84
+ function formatPick(row) {
85
+ if (!row) return null;
86
+ return {
87
+ id: row.id,
88
+ entryId: row.entry_id,
89
+ fightId: row.fight_id,
90
+ pick: row.pick,
91
+ isCorrect: row.is_correct,
92
+ // Joined fight fields (if present)
93
+ fighterAName: row.fighter_a_name || null,
94
+ fighterBName: row.fighter_b_name || null,
95
+ weightClass: row.weight_class || null,
96
+ fightWinner: row.winner || null,
97
+ fightStatus: row.fight_status || null,
98
+ };
99
+ }
100
+
101
+ // ========== POOL MANAGEMENT ==========
102
+
103
+ async function createPool({ name, espnEventId, eventDate, lockTime, solanaGameId, solanaGameAddress, createdBy }) {
104
+ const result = await pool.query(
105
+ `INSERT INTO pickem_pools (name, espn_event_id, event_date, lock_time, solana_game_id, solana_game_address, created_by)
106
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
107
+ RETURNING *`,
108
+ [name, espnEventId, eventDate, lockTime, solanaGameId || null, solanaGameAddress || null, createdBy || null]
109
+ );
110
+ return formatPool(result.rows[0]);
111
+ }
112
+
113
+ async function getPools({ status } = {}) {
114
+ let query = 'SELECT * FROM pickem_pools';
115
+ const params = [];
116
+ if (status) {
117
+ query += ' WHERE status = $1';
118
+ params.push(status);
119
+ }
120
+ query += ' ORDER BY event_date DESC';
121
+ const result = await pool.query(query, params);
122
+ return result.rows.map(formatPool);
123
+ }
124
+
125
+ async function getPoolById(poolId) {
126
+ const result = await pool.query('SELECT * FROM pickem_pools WHERE id = $1', [poolId]);
127
+ if (result.rows.length === 0) return null;
128
+ return formatPool(result.rows[0]);
129
+ }
130
+
131
+ async function updatePool(poolId, updates) {
132
+ const allowed = ['name', 'lock_time', 'status', 'solana_game_id', 'solana_game_address', 'total_entries'];
133
+ const setClauses = [];
134
+ const params = [];
135
+ let idx = 1;
136
+
137
+ for (const [key, value] of Object.entries(updates)) {
138
+ if (allowed.includes(key)) {
139
+ setClauses.push(`${key} = $${idx}`);
140
+ params.push(value);
141
+ idx++;
142
+ }
143
+ }
144
+ if (setClauses.length === 0) return getPoolById(poolId);
145
+
146
+ setClauses.push(`updated_at = NOW()`);
147
+ params.push(poolId);
148
+
149
+ const result = await pool.query(
150
+ `UPDATE pickem_pools SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`,
151
+ params
152
+ );
153
+ return formatPool(result.rows[0]);
154
+ }
155
+
156
+ // ========== FIGHT MANAGEMENT ==========
157
+
158
+ async function importFights(poolId, fights) {
159
+ const inserted = [];
160
+ for (const f of fights) {
161
+ const result = await pool.query(
162
+ `INSERT INTO pickem_fights (pool_id, espn_competition_id, fight_order,
163
+ fighter_a_name, fighter_a_headshot, fighter_a_country, fighter_a_record,
164
+ fighter_b_name, fighter_b_headshot, fighter_b_country, fighter_b_record,
165
+ weight_class)
166
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
167
+ ON CONFLICT (pool_id, espn_competition_id)
168
+ DO UPDATE SET
169
+ fight_order = EXCLUDED.fight_order,
170
+ fighter_a_name = EXCLUDED.fighter_a_name,
171
+ fighter_a_headshot = EXCLUDED.fighter_a_headshot,
172
+ fighter_a_country = EXCLUDED.fighter_a_country,
173
+ fighter_a_record = EXCLUDED.fighter_a_record,
174
+ fighter_b_name = EXCLUDED.fighter_b_name,
175
+ fighter_b_headshot = EXCLUDED.fighter_b_headshot,
176
+ fighter_b_country = EXCLUDED.fighter_b_country,
177
+ fighter_b_record = EXCLUDED.fighter_b_record,
178
+ weight_class = EXCLUDED.weight_class,
179
+ updated_at = NOW()
180
+ RETURNING *`,
181
+ [
182
+ poolId, f.espnCompetitionId, f.fightOrder,
183
+ f.fighterAName, f.fighterAHeadshot || null, f.fighterACountry || null, f.fighterARecord || null,
184
+ f.fighterBName, f.fighterBHeadshot || null, f.fighterBCountry || null, f.fighterBRecord || null,
185
+ f.weightClass || null,
186
+ ]
187
+ );
188
+ inserted.push(formatFight(result.rows[0]));
189
+ }
190
+ return inserted;
191
+ }
192
+
193
+ async function getFights(poolId) {
194
+ const result = await pool.query(
195
+ 'SELECT * FROM pickem_fights WHERE pool_id = $1 ORDER BY fight_order ASC',
196
+ [poolId]
197
+ );
198
+ return result.rows.map(formatFight);
199
+ }
200
+
201
+ async function updateFightResult(fightId, { winner, method, status }) {
202
+ const result = await pool.query(
203
+ `UPDATE pickem_fights SET winner = $1, method = $2, status = $3, updated_at = NOW()
204
+ WHERE id = $4 RETURNING *`,
205
+ [winner || null, method || null, status, fightId]
206
+ );
207
+ return result.rows[0] ? formatFight(result.rows[0]) : null;
208
+ }
209
+
210
+ // ========== ENTRY MANAGEMENT ==========
211
+
212
+ async function joinPool({ poolId, userId, walletAddress, txSignature }) {
213
+ const client = await pool.connect();
214
+ try {
215
+ await client.query('BEGIN');
216
+
217
+ // Check pool exists and is open
218
+ const poolResult = await client.query(
219
+ 'SELECT * FROM pickem_pools WHERE id = $1',
220
+ [poolId]
221
+ );
222
+ if (poolResult.rows.length === 0) throw new Error('Pool not found');
223
+ const pickemPool = poolResult.rows[0];
224
+ if (pickemPool.status !== 'open') throw new Error(`Pool is ${pickemPool.status}, cannot join`);
225
+
226
+ // Insert entry
227
+ const entryResult = await client.query(
228
+ `INSERT INTO pickem_entries (pool_id, user_id, wallet_address, entry_tx_signature)
229
+ VALUES ($1, $2, $3, $4)
230
+ RETURNING *`,
231
+ [poolId, userId, walletAddress, txSignature || null]
232
+ );
233
+
234
+ // Increment total_entries
235
+ await client.query(
236
+ 'UPDATE pickem_pools SET total_entries = total_entries + 1, updated_at = NOW() WHERE id = $1',
237
+ [poolId]
238
+ );
239
+
240
+ await client.query('COMMIT');
241
+ return formatEntry(entryResult.rows[0]);
242
+ } catch (error) {
243
+ await client.query('ROLLBACK');
244
+ throw error;
245
+ } finally {
246
+ client.release();
247
+ }
248
+ }
249
+
250
+ async function getUserEntry(poolId, userId) {
251
+ const result = await pool.query(
252
+ `SELECT pe.*, u.username, u.avatar
253
+ FROM pickem_entries pe
254
+ LEFT JOIN users u ON pe.user_id = u.id
255
+ WHERE pe.pool_id = $1 AND pe.user_id = $2`,
256
+ [poolId, userId]
257
+ );
258
+ if (result.rows.length === 0) return null;
259
+
260
+ const entry = formatEntry(result.rows[0]);
261
+
262
+ // Attach picks
263
+ const picksResult = await pool.query(
264
+ `SELECT pp.*, pf.fighter_a_name, pf.fighter_b_name, pf.weight_class, pf.winner, pf.status as fight_status
265
+ FROM pickem_picks pp
266
+ JOIN pickem_fights pf ON pp.fight_id = pf.id
267
+ WHERE pp.entry_id = $1
268
+ ORDER BY pf.fight_order ASC`,
269
+ [entry.id]
270
+ );
271
+ entry.picks = picksResult.rows.map(formatPick);
272
+
273
+ return entry;
274
+ }
275
+
276
+ // ========== PICKS ==========
277
+
278
+ async function submitPicks({ poolId, userId, picks }) {
279
+ const client = await pool.connect();
280
+ try {
281
+ await client.query('BEGIN');
282
+
283
+ // 1. Validate pool is open and not past lock_time
284
+ const poolResult = await client.query(
285
+ 'SELECT * FROM pickem_pools WHERE id = $1',
286
+ [poolId]
287
+ );
288
+ if (poolResult.rows.length === 0) throw new Error('Pool not found');
289
+ const pickemPool = poolResult.rows[0];
290
+ if (pickemPool.status !== 'open') throw new Error(`Pool is ${pickemPool.status}, cannot submit picks`);
291
+ if (new Date() >= new Date(pickemPool.lock_time)) throw new Error('Lock time has passed, picks are closed');
292
+
293
+ // 2. Validate entry exists
294
+ const entryResult = await client.query(
295
+ 'SELECT * FROM pickem_entries WHERE pool_id = $1 AND user_id = $2',
296
+ [poolId, userId]
297
+ );
298
+ if (entryResult.rows.length === 0) throw new Error('Must join pool before submitting picks');
299
+ const entry = entryResult.rows[0];
300
+
301
+ // 3. Validate all active fights are covered
302
+ const fightsResult = await client.query(
303
+ "SELECT id FROM pickem_fights WHERE pool_id = $1 AND status NOT IN ('cancelled', 'no_contest')",
304
+ [poolId]
305
+ );
306
+ const validFightIds = new Set(fightsResult.rows.map(r => r.id));
307
+
308
+ if (picks.length !== validFightIds.size) {
309
+ throw new Error(`Must pick all ${validFightIds.size} fights, received ${picks.length}`);
310
+ }
311
+ for (const p of picks) {
312
+ if (!validFightIds.has(p.fightId)) throw new Error(`Invalid fight ID: ${p.fightId}`);
313
+ if (p.pick !== 'a' && p.pick !== 'b') throw new Error(`Invalid pick "${p.pick}", must be "a" or "b"`);
314
+ }
315
+
316
+ // 4. Upsert all picks
317
+ const insertedPicks = [];
318
+ for (const p of picks) {
319
+ const result = await client.query(
320
+ `INSERT INTO pickem_picks (entry_id, fight_id, pick)
321
+ VALUES ($1, $2, $3)
322
+ ON CONFLICT (entry_id, fight_id)
323
+ DO UPDATE SET pick = EXCLUDED.pick, updated_at = NOW()
324
+ RETURNING *`,
325
+ [entry.id, p.fightId, p.pick]
326
+ );
327
+ insertedPicks.push(result.rows[0]);
328
+ }
329
+
330
+ await client.query('COMMIT');
331
+ return insertedPicks.map(formatPick);
332
+ } catch (error) {
333
+ await client.query('ROLLBACK');
334
+ throw error;
335
+ } finally {
336
+ client.release();
337
+ }
338
+ }
339
+
340
+ async function getUserPicks(poolId, userId) {
341
+ const entryResult = await pool.query(
342
+ 'SELECT id FROM pickem_entries WHERE pool_id = $1 AND user_id = $2',
343
+ [poolId, userId]
344
+ );
345
+ if (entryResult.rows.length === 0) return [];
346
+
347
+ const result = await pool.query(
348
+ `SELECT pp.*, pf.fighter_a_name, pf.fighter_b_name, pf.weight_class, pf.winner, pf.status as fight_status
349
+ FROM pickem_picks pp
350
+ JOIN pickem_fights pf ON pp.fight_id = pf.id
351
+ WHERE pp.entry_id = $1
352
+ ORDER BY pf.fight_order ASC`,
353
+ [entryResult.rows[0].id]
354
+ );
355
+ return result.rows.map(formatPick);
356
+ }
357
+
358
+ // ========== SCORING ==========
359
+
360
+ async function computeScores(poolId) {
361
+ const client = await pool.connect();
362
+ try {
363
+ await client.query('BEGIN');
364
+
365
+ // 1. Mark picks correct/incorrect based on resolved fights
366
+ await client.query(
367
+ `UPDATE pickem_picks pp
368
+ SET is_correct = (pp.pick = pf.winner), updated_at = NOW()
369
+ FROM pickem_fights pf
370
+ WHERE pp.fight_id = pf.id
371
+ AND pf.pool_id = $1
372
+ AND pf.status = 'final'
373
+ AND pf.winner IS NOT NULL`,
374
+ [poolId]
375
+ );
376
+
377
+ // 2. Cancelled/no_contest fights: exclude from scoring
378
+ await client.query(
379
+ `UPDATE pickem_picks pp
380
+ SET is_correct = NULL, updated_at = NOW()
381
+ FROM pickem_fights pf
382
+ WHERE pp.fight_id = pf.id
383
+ AND pf.pool_id = $1
384
+ AND pf.status IN ('cancelled', 'no_contest')`,
385
+ [poolId]
386
+ );
387
+
388
+ // 3. Compute scores: count correct picks per entry
389
+ await client.query(
390
+ `UPDATE pickem_entries pe
391
+ SET score = (
392
+ SELECT COUNT(*) FROM pickem_picks pp WHERE pp.entry_id = pe.id AND pp.is_correct = true
393
+ ),
394
+ updated_at = NOW()
395
+ WHERE pe.pool_id = $1`,
396
+ [poolId]
397
+ );
398
+
399
+ // 4. Compute ranks using DENSE_RANK
400
+ await client.query(
401
+ `UPDATE pickem_entries pe
402
+ SET rank = sub.rank
403
+ FROM (
404
+ SELECT id, DENSE_RANK() OVER (ORDER BY score DESC) as rank
405
+ FROM pickem_entries WHERE pool_id = $1
406
+ ) sub
407
+ WHERE pe.id = sub.id`,
408
+ [poolId]
409
+ );
410
+
411
+ // 5. Get results
412
+ const result = await client.query(
413
+ 'SELECT pe.*, u.username, u.avatar FROM pickem_entries pe LEFT JOIN users u ON pe.user_id = u.id WHERE pe.pool_id = $1 ORDER BY score DESC, created_at ASC',
414
+ [poolId]
415
+ );
416
+
417
+ const entries = result.rows.map(formatEntry);
418
+ const maxScore = entries.length > 0 ? entries[0].score : 0;
419
+ const winners = entries.filter(e => e.score === maxScore);
420
+
421
+ await client.query('COMMIT');
422
+
423
+ return { entries, maxScore, winnerCount: winners.length, winners };
424
+ } catch (error) {
425
+ await client.query('ROLLBACK');
426
+ throw error;
427
+ } finally {
428
+ client.release();
429
+ }
430
+ }
431
+
432
+ // ========== LEADERBOARD ==========
433
+
434
+ async function getLeaderboard(poolId) {
435
+ const result = await pool.query(
436
+ `SELECT pe.*, u.username, u.avatar
437
+ FROM pickem_entries pe
438
+ LEFT JOIN users u ON pe.user_id = u.id
439
+ WHERE pe.pool_id = $1
440
+ ORDER BY pe.score DESC, pe.created_at ASC`,
441
+ [poolId]
442
+ );
443
+
444
+ const entries = result.rows.map(formatEntry);
445
+ const entryIds = entries.map(e => e.id);
446
+
447
+ if (entryIds.length > 0) {
448
+ const picksResult = await pool.query(
449
+ `SELECT pp.*, pf.fighter_a_name, pf.fighter_b_name, pf.weight_class,
450
+ pf.winner, pf.status as fight_status, pf.fight_order
451
+ FROM pickem_picks pp
452
+ JOIN pickem_fights pf ON pp.fight_id = pf.id
453
+ WHERE pp.entry_id = ANY($1)
454
+ ORDER BY pf.fight_order ASC`,
455
+ [entryIds]
456
+ );
457
+
458
+ const picksByEntry = {};
459
+ for (const row of picksResult.rows) {
460
+ if (!picksByEntry[row.entry_id]) picksByEntry[row.entry_id] = [];
461
+ picksByEntry[row.entry_id].push({
462
+ ...formatPick(row),
463
+ fightOrder: row.fight_order,
464
+ });
465
+ }
466
+
467
+ return entries.map(e => ({ ...e, picks: picksByEntry[e.id] || [] }));
468
+ }
469
+
470
+ return entries;
471
+ }
472
+
473
+ async function getWinners(poolId) {
474
+ const result = await pool.query(
475
+ `SELECT pe.*, u.username, u.avatar
476
+ FROM pickem_entries pe
477
+ LEFT JOIN users u ON pe.user_id = u.id
478
+ WHERE pe.pool_id = $1 AND pe.rank = 1`,
479
+ [poolId]
480
+ );
481
+ return result.rows.map(formatEntry);
482
+ }
483
+
484
+ // ========== STATS ==========
485
+
486
+ async function getPoolStats(poolId) {
487
+ const poolResult = await pool.query('SELECT * FROM pickem_pools WHERE id = $1', [poolId]);
488
+ if (poolResult.rows.length === 0) return null;
489
+ const p = poolResult.rows[0];
490
+
491
+ const fightsResult = await pool.query(
492
+ "SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status IN ('final', 'cancelled', 'no_contest')) as resolved FROM pickem_fights WHERE pool_id = $1",
493
+ [poolId]
494
+ );
495
+ const { total: totalFights, resolved: resolvedFights } = fightsResult.rows[0];
496
+
497
+ const totalEntries = Number(p.total_entries || 0);
498
+ const totalPotLamports = totalEntries * Number(p.buy_in_lamports);
499
+
500
+ return {
501
+ totalEntries,
502
+ totalPotLamports,
503
+ totalPotSol: totalPotLamports / LAMPORTS_PER_SOL,
504
+ netPotLamports: Math.floor(totalPotLamports * (1 - FEE_PERCENT / 100)),
505
+ netPotSol: Math.floor(totalPotLamports * (1 - FEE_PERCENT / 100)) / LAMPORTS_PER_SOL,
506
+ totalFights: Number(totalFights),
507
+ resolvedFights: Number(resolvedFights),
508
+ allFightsResolved: Number(resolvedFights) === Number(totalFights) && Number(totalFights) > 0,
509
+ status: p.status,
510
+ lockTime: p.lock_time,
511
+ };
512
+ }
513
+
514
+ // ========== PAYOUTS ==========
515
+
516
+ async function recordPayout({ poolId, entryId, walletAddress, amountLamports, txSignature, status }) {
517
+ const result = await pool.query(
518
+ `INSERT INTO pickem_payouts (pool_id, entry_id, wallet_address, amount_lamports, payout_tx_signature, status)
519
+ VALUES ($1, $2, $3, $4, $5, $6)
520
+ ON CONFLICT (pool_id, entry_id)
521
+ DO UPDATE SET payout_tx_signature = EXCLUDED.payout_tx_signature, status = EXCLUDED.status, updated_at = NOW()
522
+ RETURNING *`,
523
+ [poolId, entryId, walletAddress, amountLamports, txSignature || null, status || 'pending']
524
+ );
525
+ return result.rows[0];
526
+ }
527
+
528
+ async function getPayouts(poolId) {
529
+ const result = await pool.query(
530
+ 'SELECT * FROM pickem_payouts WHERE pool_id = $1 ORDER BY amount_lamports DESC',
531
+ [poolId]
532
+ );
533
+ return result.rows;
534
+ }
535
+
536
+ module.exports = {
537
+ createPool,
538
+ getPools,
539
+ getPoolById,
540
+ updatePool,
541
+ importFights,
542
+ getFights,
543
+ updateFightResult,
544
+ joinPool,
545
+ getUserEntry,
546
+ submitPicks,
547
+ getUserPicks,
548
+ computeScores,
549
+ getLeaderboard,
550
+ getWinners,
551
+ getPoolStats,
552
+ recordPayout,
553
+ getPayouts,
554
+ };