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,4201 @@
1
+ /**
2
+ * 🔧 Developer API Routes
3
+ *
4
+ * Public developer API for third-party apps to integrate Dubs betting.
5
+ * Two sections:
6
+ * 1. Portal routes (JWT auth) — developer account/app/key management
7
+ * 2. Public API routes (API key auth) — game lifecycle, sports data
8
+ *
9
+ * Mounted at:
10
+ * /api/developer — Portal management (JWT auth)
11
+ * /api/developer/v1 — Public API (API key auth)
12
+ */
13
+
14
+ const express = require('express');
15
+ const crypto = require('crypto');
16
+ const axios = require('axios');
17
+ const nacl = require('tweetnacl');
18
+ const bs58 = require('bs58').default;
19
+ const { PublicKey, Connection, Transaction } = require('@solana/web3.js');
20
+
21
+ // Solana connection for transaction simulation
22
+ const RPC_URL = process.env.SOLANA_NETWORK || 'http://127.0.0.1:8899';
23
+ const connection = new Connection(RPC_URL, 'confirmed');
24
+ const { pool } = require('../services/db');
25
+ const { authenticate, generateToken, createSession, deleteSession, hashToken, JWT_EXPIRES_IN } = require('../middleware/authenticate');
26
+ const { apiKeyAuth, hashApiKey, logApiCall } = require('../middleware/apiKeyAuth');
27
+ const { developerUserAuth } = require('../middleware/developerUserAuth');
28
+ const { fetchScoresForLeague, fetchUFCScores, ESPN_URLS } = require('../controllers/livescoresController');
29
+ const { normalizeLeague } = require('../services/automaticGameOracle');
30
+ const { getCustomGameResolver } = require('../services/customGameResolver');
31
+ const PortfolioService = require('../services/portfolioService');
32
+ const expoPushService = require('../services/expoPushService');
33
+
34
+ // Singleton for SOL balance lookups (uses Alchemy RPC with 30s Redis cache)
35
+ const portfolioService = new PortfolioService();
36
+
37
+ // Two routers: one for portal (JWT), one for public API (API key)
38
+ const portalRouter = express.Router();
39
+ const apiRouter = express.Router();
40
+
41
+ // ============================================================
42
+ // UNIFIED EVENTS — Constants and Mappings
43
+ // ============================================================
44
+
45
+ const BASE_URL_INTERNAL = `http://localhost:${process.env.PORT || 3001}`;
46
+ const ALL_SPORTS_LEAGUES = ['NBA', 'NHL', 'MLB', 'NFL', 'EPL', 'UFC', 'NCAAF', 'NCAAB'];
47
+ const ALL_ESPORTS_VIDEOGAMES = ['cs-go', 'valorant'];
48
+
49
+ /** Map from `game` query param to internal source identifier. Accepts slugs and aliases. */
50
+ const GAME_PARAM_MAP = {
51
+ 'nba': { type: 'sports', league: 'NBA' },
52
+ 'nhl': { type: 'sports', league: 'NHL' },
53
+ 'mlb': { type: 'sports', league: 'MLB' },
54
+ 'nfl': { type: 'sports', league: 'NFL' },
55
+ 'epl': { type: 'sports', league: 'EPL' },
56
+ 'ufc': { type: 'sports', league: 'UFC' },
57
+ 'ncaaf': { type: 'sports', league: 'NCAAF' },
58
+ 'ncaab': { type: 'sports', league: 'NCAAB' },
59
+ 'cs-go': { type: 'esports', videogame: 'cs-go' },
60
+ 'cs2': { type: 'esports', videogame: 'cs-go' },
61
+ 'counter-strike': { type: 'esports', videogame: 'cs-go' },
62
+ 'valorant': { type: 'esports', videogame: 'valorant' },
63
+ 'codmw': { type: 'esports', videogame: 'codmw' },
64
+ };
65
+
66
+ const LEAGUE_TO_GAME = {
67
+ 'NBA': 'Basketball', 'NHL': 'Ice Hockey', 'MLB': 'Baseball',
68
+ 'NFL': 'American Football', 'EPL': 'Soccer', 'UFC': 'Fighting',
69
+ 'NCAAF': 'American Football', 'NCAAB': 'Basketball',
70
+ };
71
+
72
+ // Maps league abbreviation → value stored in sports_event.strLeague
73
+ const LEAGUE_ABBREV_TO_DB = {
74
+ NBA: 'NBA', NHL: 'NHL', MLB: 'MLB', NFL: 'NFL',
75
+ EPL: 'English Premier League', UFC: 'UFC',
76
+ NCAAB: 'NCAAB', NCAAF: 'NCAAF',
77
+ };
78
+
79
+ /** Map internal DB status to public API status */
80
+ function publicGameStatus(s) {
81
+ return s === 'pending' ? 'open' : s;
82
+ }
83
+
84
+ /** Normalize a raw games DB row into the public API GameListItem shape */
85
+ function normalizeGameRow(g) {
86
+ const se = g.sports_event || {};
87
+ return {
88
+ gameId: g.game_id,
89
+ title: g.title,
90
+ buyIn: parseFloat(g.buy_in),
91
+ gameMode: g.game_mode,
92
+ isLocked: g.is_locked,
93
+ isResolved: g.is_resolved,
94
+ status: publicGameStatus(g.automatic_status),
95
+ totalPool: parseFloat(g.total_pool) || 0,
96
+ league: se.strLeague || null,
97
+ lockTimestamp: g.lock_timestamp,
98
+ createdAt: g.created_at,
99
+ opponents: [
100
+ { name: se.strHomeTeam || null, imageUrl: se.strHomeTeamBadge || null },
101
+ { name: se.strAwayTeam || null, imageUrl: se.strAwayTeamBadge || null },
102
+ ],
103
+ media: {
104
+ poster: g.matchup_image_url || se.strPoster || null,
105
+ thumbnail: g.matchup_image_url || se.strThumb || null,
106
+ },
107
+ };
108
+ }
109
+
110
+ // ============================================================
111
+ // UNIFIED NORMALIZERS — Single shape for all event types
112
+ // ============================================================
113
+
114
+ function normalizeTimestamp(ts) {
115
+ if (!ts) return null;
116
+ if (ts.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(ts)) return ts;
117
+ return ts + 'Z';
118
+ }
119
+
120
+ function normalizeSportsStatus(strStatus) {
121
+ if (!strStatus) return 'upcoming';
122
+ const s = strStatus.toLowerCase().trim();
123
+ if (['ns', 'not started'].includes(s)) return 'upcoming';
124
+ if (['ft', 'match finished', 'aet', 'ap'].includes(s)) return 'finished';
125
+ if (['postponed', 'canceled', 'cancelled', 'abandoned'].includes(s)) return 'canceled';
126
+ return 'live';
127
+ }
128
+
129
+ function normalizeEsportsStatus(status) {
130
+ if (!status) return 'upcoming';
131
+ switch (status) {
132
+ case 'not_started': return 'upcoming';
133
+ case 'running': return 'live';
134
+ case 'finished': return 'finished';
135
+ case 'canceled':
136
+ case 'postponed': return 'canceled';
137
+ default: return 'upcoming';
138
+ }
139
+ }
140
+
141
+ /** TheSportsDB event → unified shape */
142
+ function normalizeToUnifiedSportsEvent(raw, leagueAbbrev) {
143
+ return {
144
+ id: `sports:${leagueAbbrev}:${raw.idEvent}`,
145
+ type: 'sports',
146
+ title: raw.strEvent,
147
+ league: raw.strLeague || leagueAbbrev,
148
+ game: raw.strSport || LEAGUE_TO_GAME[leagueAbbrev] || null,
149
+ startTime: normalizeTimestamp(raw.strTimestamp),
150
+ status: normalizeSportsStatus(raw.strStatus),
151
+ tier: null,
152
+ venue: raw.strVenue || null,
153
+ opponents: [
154
+ { name: raw.strHomeTeam, imageUrl: raw.strHomeTeamBadge || null, score: raw.intHomeScore != null ? parseInt(raw.intHomeScore) : null },
155
+ { name: raw.strAwayTeam, imageUrl: raw.strAwayTeamBadge || null, score: raw.intAwayScore != null ? parseInt(raw.intAwayScore) : null },
156
+ ],
157
+ media: {
158
+ poster: raw.strPoster || null,
159
+ thumbnail: raw.strThumb || null,
160
+ streams: [],
161
+ },
162
+ meta: {
163
+ matchType: null,
164
+ numberOfGames: null,
165
+ tournament: null,
166
+ country: raw.strCountry || null,
167
+ },
168
+ };
169
+ }
170
+
171
+ /** Esports upcoming match (already normalized by dubs-server) → unified shape */
172
+ function normalizeToUnifiedEsportsEvent(raw) {
173
+ return {
174
+ id: `esports:${raw.id}`,
175
+ type: 'esports',
176
+ title: raw.name,
177
+ league: raw.league || null,
178
+ game: raw.videogameName || raw.videogame || null,
179
+ startTime: normalizeTimestamp(raw.scheduledAt),
180
+ status: normalizeEsportsStatus(raw.status || 'not_started'),
181
+ tier: raw.tier || null,
182
+ venue: null,
183
+ opponents: (raw.opponents || []).map(o => ({
184
+ name: o.name,
185
+ imageUrl: o.imageUrl || null,
186
+ score: null,
187
+ })),
188
+ media: {
189
+ poster: null,
190
+ thumbnail: null,
191
+ streams: (raw.streams || []).map(s => {
192
+ if (typeof s === 'string') return { url: s, language: null };
193
+ return { url: s.raw_url || s.url || null, language: s.language || null };
194
+ }),
195
+ },
196
+ meta: {
197
+ matchType: raw.matchType || null,
198
+ numberOfGames: raw.numberOfGames || null,
199
+ tournament: raw.tournament || null,
200
+ country: null,
201
+ },
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Transform raw PandaScore match detail (single match endpoint) into clean shape.
207
+ * Used only by GET /v1/esports/matches/:matchId (detail view).
208
+ */
209
+ function transformEsportsMatchDetail(raw) {
210
+ const opponents = (raw.opponents || []).map(slot => {
211
+ const o = slot.opponent || slot;
212
+ return {
213
+ id: o.id,
214
+ name: o.name,
215
+ acronym: o.acronym || null,
216
+ imageUrl: o.image_url || o.imageUrl || null,
217
+ };
218
+ });
219
+
220
+ return {
221
+ matchId: raw.id,
222
+ title: raw.name,
223
+ status: raw.status,
224
+ videogame: raw.videogame?.name || raw.videogame || null,
225
+ league: raw.league?.name || null,
226
+ serie: raw.serie?.full_name || raw.serie?.name || null,
227
+ tournament: raw.tournament?.name || null,
228
+ tier: raw.tournament?.tier || raw.serie?.tier || null,
229
+ startTime: raw.scheduled_at || raw.begin_at,
230
+ endTime: raw.end_at || null,
231
+ matchType: raw.match_type || null,
232
+ numberOfGames: raw.number_of_games || null,
233
+ opponents,
234
+ results: (raw.results || []).map(r => ({
235
+ teamId: r.team_id,
236
+ score: r.score,
237
+ })),
238
+ winnerId: raw.winner_id || null,
239
+ };
240
+ }
241
+
242
+ // ============================================================
243
+ // INTERNAL FETCHERS — Reusable localhost proxy calls
244
+ // ============================================================
245
+
246
+ async function fetchSportsEvents(league) {
247
+ try {
248
+ const response = await axios.get(`${BASE_URL_INTERNAL}/api/sports/events/${league}`, { timeout: 15000 });
249
+ const rawData = response.data;
250
+ return rawData?.data?.events || rawData?.events || [];
251
+ } catch (error) {
252
+ console.error(`[DevAPI] Error fetching sports events for ${league}:`, error.message);
253
+ return [];
254
+ }
255
+ }
256
+
257
+ async function fetchEsportsMatches(videogame) {
258
+ try {
259
+ const params = new URLSearchParams();
260
+ if (videogame) params.set('videogame', videogame);
261
+ params.set('per_page', '50');
262
+ params.set('page', '1');
263
+ const response = await axios.get(`${BASE_URL_INTERNAL}/api/esports/games/upcoming?${params.toString()}`, { timeout: 15000 });
264
+ return response.data?.data || [];
265
+ } catch (error) {
266
+ console.error(`[DevAPI] Error fetching esports matches:`, error.message);
267
+ return [];
268
+ }
269
+ }
270
+
271
+ // ============================================================
272
+ // PORTAL ROUTES — JWT auth (developer dashboard management)
273
+ // ============================================================
274
+
275
+ /**
276
+ * POST /api/developer/account
277
+ * Create or get developer account (called on first portal login)
278
+ */
279
+ portalRouter.post('/account', authenticate, async (req, res) => {
280
+ try {
281
+ const walletAddress = req.user.walletAddress;
282
+ const { displayName, email, commissionWallet } = req.body;
283
+
284
+ // Use the developer's own wallet for commission by default
285
+ const finalCommissionWallet = commissionWallet || walletAddress;
286
+
287
+ const result = await pool.query(`
288
+ INSERT INTO developer_accounts (wallet_address, display_name, email, commission_wallet)
289
+ VALUES ($1, $2, $3, $4)
290
+ ON CONFLICT (wallet_address)
291
+ DO UPDATE SET
292
+ display_name = COALESCE(EXCLUDED.display_name, developer_accounts.display_name),
293
+ email = COALESCE(EXCLUDED.email, developer_accounts.email),
294
+ commission_wallet = COALESCE(EXCLUDED.commission_wallet, developer_accounts.commission_wallet),
295
+ updated_at = NOW()
296
+ RETURNING *
297
+ `, [walletAddress, displayName || null, email || null, finalCommissionWallet]);
298
+
299
+ res.json({ success: true, account: result.rows[0] });
300
+ } catch (error) {
301
+ console.error('[Developer] Error creating account:', error.message);
302
+ res.status(500).json({ success: false, error: 'Failed to create developer account' });
303
+ }
304
+ });
305
+
306
+ /**
307
+ * GET /api/developer/account
308
+ * Get current developer account
309
+ */
310
+ portalRouter.get('/account', authenticate, async (req, res) => {
311
+ try {
312
+ const result = await pool.query(
313
+ 'SELECT * FROM developer_accounts WHERE wallet_address = $1',
314
+ [req.user.walletAddress]
315
+ );
316
+
317
+ if (result.rows.length === 0) {
318
+ return res.status(404).json({ success: false, error: 'Developer account not found' });
319
+ }
320
+
321
+ res.json({ success: true, account: result.rows[0] });
322
+ } catch (error) {
323
+ console.error('[Developer] Error fetching account:', error.message);
324
+ res.status(500).json({ success: false, error: 'Failed to fetch developer account' });
325
+ }
326
+ });
327
+
328
+ /**
329
+ * PATCH /api/developer/account
330
+ * Update developer account (display name, email, commission wallet)
331
+ */
332
+ portalRouter.patch('/account', authenticate, async (req, res) => {
333
+ try {
334
+ const { displayName, email, commissionWallet } = req.body;
335
+
336
+ const result = await pool.query(`
337
+ UPDATE developer_accounts
338
+ SET display_name = COALESCE($2, display_name),
339
+ email = COALESCE($3, email),
340
+ commission_wallet = COALESCE($4, commission_wallet),
341
+ updated_at = NOW()
342
+ WHERE wallet_address = $1
343
+ RETURNING *
344
+ `, [req.user.walletAddress, displayName, email, commissionWallet]);
345
+
346
+ if (result.rows.length === 0) {
347
+ return res.status(404).json({ success: false, error: 'Developer account not found' });
348
+ }
349
+
350
+ res.json({ success: true, account: result.rows[0] });
351
+ } catch (error) {
352
+ console.error('[Developer] Error updating account:', error.message);
353
+ res.status(500).json({ success: false, error: 'Failed to update developer account' });
354
+ }
355
+ });
356
+
357
+ // ── Apps ──
358
+
359
+ /**
360
+ * POST /api/developer/apps
361
+ * Create a new app
362
+ */
363
+ portalRouter.post('/apps', authenticate, async (req, res) => {
364
+ try {
365
+ const { appName, description, websiteUrl } = req.body;
366
+
367
+ if (!appName) {
368
+ return res.status(400).json({ success: false, error: 'appName is required' });
369
+ }
370
+
371
+ // Look up developer account
372
+ const dev = await pool.query(
373
+ 'SELECT id FROM developer_accounts WHERE wallet_address = $1',
374
+ [req.user.walletAddress]
375
+ );
376
+
377
+ if (dev.rows.length === 0) {
378
+ return res.status(404).json({ success: false, error: 'Create a developer account first' });
379
+ }
380
+
381
+ const developerId = dev.rows[0].id;
382
+
383
+ const resolutionSecret = crypto.randomBytes(32).toString('hex');
384
+
385
+ const result = await pool.query(`
386
+ INSERT INTO developer_apps (developer_id, app_name, description, website_url, resolution_secret)
387
+ VALUES ($1, $2, $3, $4, $5)
388
+ RETURNING *
389
+ `, [developerId, appName, description || null, websiteUrl || null, resolutionSecret]);
390
+
391
+ res.json({ success: true, app: result.rows[0] });
392
+ } catch (error) {
393
+ console.error('[Developer] Error creating app:', error.message);
394
+ res.status(500).json({ success: false, error: 'Failed to create app' });
395
+ }
396
+ });
397
+
398
+ /**
399
+ * GET /api/developer/apps
400
+ * List all apps for current developer
401
+ */
402
+ portalRouter.get('/apps', authenticate, async (req, res) => {
403
+ try {
404
+ const result = await pool.query(`
405
+ SELECT a.* FROM developer_apps a
406
+ JOIN developer_accounts d ON a.developer_id = d.id
407
+ WHERE d.wallet_address = $1 AND a.status != 'deleted'
408
+ ORDER BY a.created_at DESC
409
+ `, [req.user.walletAddress]);
410
+
411
+ res.json({ success: true, apps: result.rows });
412
+ } catch (error) {
413
+ console.error('[Developer] Error listing apps:', error.message);
414
+ res.status(500).json({ success: false, error: 'Failed to list apps' });
415
+ }
416
+ });
417
+
418
+ /**
419
+ * GET /api/developer/apps/:appId
420
+ * Get a single app with its API keys (hints only)
421
+ */
422
+ portalRouter.get('/apps/:appId', authenticate, async (req, res) => {
423
+ try {
424
+ const { appId } = req.params;
425
+
426
+ const appResult = await pool.query(`
427
+ SELECT a.* FROM developer_apps a
428
+ JOIN developer_accounts d ON a.developer_id = d.id
429
+ WHERE a.id = $1 AND d.wallet_address = $2
430
+ `, [appId, req.user.walletAddress]);
431
+
432
+ if (appResult.rows.length === 0) {
433
+ return res.status(404).json({ success: false, error: 'App not found' });
434
+ }
435
+
436
+ // Get API keys (hints only, never full keys)
437
+ const keysResult = await pool.query(`
438
+ SELECT id, key_prefix, key_hint, environment, is_active, created_at, last_used_at
439
+ FROM developer_api_keys
440
+ WHERE app_id = $1
441
+ ORDER BY created_at DESC
442
+ `, [appId]);
443
+
444
+ res.json({
445
+ success: true,
446
+ app: appResult.rows[0],
447
+ apiKeys: keysResult.rows,
448
+ });
449
+ } catch (error) {
450
+ console.error('[Developer] Error fetching app:', error.message);
451
+ res.status(500).json({ success: false, error: 'Failed to fetch app' });
452
+ }
453
+ });
454
+
455
+ /**
456
+ * PATCH /api/developer/apps/:appId
457
+ * Update app details
458
+ */
459
+ portalRouter.patch('/apps/:appId', authenticate, async (req, res) => {
460
+ try {
461
+ const { appId } = req.params;
462
+ const { appName, description, websiteUrl, networkMode, uiConfig } = req.body;
463
+
464
+ // Validate networkMode if provided
465
+ if (networkMode && !['open', 'private'].includes(networkMode)) {
466
+ return res.status(400).json({ success: false, error: 'networkMode must be "open" or "private"' });
467
+ }
468
+
469
+ const result = await pool.query(`
470
+ UPDATE developer_apps
471
+ SET app_name = COALESCE($3, app_name),
472
+ description = COALESCE($4, description),
473
+ website_url = COALESCE($5, website_url),
474
+ network_mode = COALESCE($6, network_mode),
475
+ ui_config = COALESCE($7, ui_config),
476
+ updated_at = NOW()
477
+ FROM developer_accounts d
478
+ WHERE developer_apps.id = $1
479
+ AND developer_apps.developer_id = d.id
480
+ AND d.wallet_address = $2
481
+ RETURNING developer_apps.*
482
+ `, [appId, req.user.walletAddress, appName, description, websiteUrl, networkMode, uiConfig ? JSON.stringify(uiConfig) : null]);
483
+
484
+ if (result.rows.length === 0) {
485
+ return res.status(404).json({ success: false, error: 'App not found' });
486
+ }
487
+
488
+ res.json({ success: true, app: result.rows[0] });
489
+ } catch (error) {
490
+ console.error('[Developer] Error updating app:', error.message);
491
+ res.status(500).json({ success: false, error: 'Failed to update app' });
492
+ }
493
+ });
494
+
495
+ /**
496
+ * DELETE /api/developer/apps/:appId
497
+ * Soft-delete an app (sets status to 'deleted', deactivates all keys)
498
+ */
499
+ portalRouter.delete('/apps/:appId', authenticate, async (req, res) => {
500
+ try {
501
+ const { appId } = req.params;
502
+
503
+ // Verify ownership
504
+ const appResult = await pool.query(`
505
+ SELECT a.id FROM developer_apps a
506
+ JOIN developer_accounts d ON a.developer_id = d.id
507
+ WHERE a.id = $1 AND d.wallet_address = $2
508
+ `, [appId, req.user.walletAddress]);
509
+
510
+ if (appResult.rows.length === 0) {
511
+ return res.status(404).json({ success: false, error: 'App not found' });
512
+ }
513
+
514
+ // Deactivate all keys
515
+ await pool.query('UPDATE developer_api_keys SET is_active = FALSE WHERE app_id = $1', [appId]);
516
+
517
+ // Soft delete the app
518
+ await pool.query(
519
+ "UPDATE developer_apps SET status = 'deleted', updated_at = NOW() WHERE id = $1",
520
+ [appId]
521
+ );
522
+
523
+ res.json({ success: true, message: 'App deleted' });
524
+ } catch (error) {
525
+ console.error('[Developer] Error deleting app:', error.message);
526
+ res.status(500).json({ success: false, error: 'Failed to delete app' });
527
+ }
528
+ });
529
+
530
+ // ── API Keys ──
531
+
532
+ /**
533
+ * Generate a random API key with prefix
534
+ * Returns: { fullKey, keyHash, keyHint, keyPrefix }
535
+ */
536
+ function generateApiKey(environment) {
537
+ const prefix = environment === 'production' ? 'dubs_live_' : 'dubs_test_';
538
+ const randomPart = crypto.randomBytes(24).toString('hex'); // 48 hex chars
539
+ const fullKey = prefix + randomPart;
540
+ const keyHash = hashApiKey(fullKey);
541
+ const keyHint = randomPart.slice(-4);
542
+
543
+ return { fullKey, keyHash, keyHint, keyPrefix: prefix };
544
+ }
545
+
546
+ /**
547
+ * POST /api/developer/apps/:appId/keys
548
+ * Generate a new API key for an app
549
+ * Body: { environment: 'sandbox' | 'production' }
550
+ * Returns full key ONCE — never shown again
551
+ */
552
+ portalRouter.post('/apps/:appId/keys', authenticate, async (req, res) => {
553
+ try {
554
+ const { appId } = req.params;
555
+ const { environment = 'sandbox' } = req.body;
556
+
557
+ if (!['sandbox', 'production'].includes(environment)) {
558
+ return res.status(400).json({ success: false, error: 'environment must be sandbox or production' });
559
+ }
560
+
561
+ // Verify ownership
562
+ const appResult = await pool.query(`
563
+ SELECT a.id FROM developer_apps a
564
+ JOIN developer_accounts d ON a.developer_id = d.id
565
+ WHERE a.id = $1 AND d.wallet_address = $2 AND a.status = 'active'
566
+ `, [appId, req.user.walletAddress]);
567
+
568
+ if (appResult.rows.length === 0) {
569
+ return res.status(404).json({ success: false, error: 'App not found or not active' });
570
+ }
571
+
572
+ const { fullKey, keyHash, keyHint, keyPrefix } = generateApiKey(environment);
573
+
574
+ await pool.query(`
575
+ INSERT INTO developer_api_keys (app_id, key_prefix, key_hash, key_hint, environment)
576
+ VALUES ($1, $2, $3, $4, $5)
577
+ `, [appId, keyPrefix, keyHash, keyHint, environment]);
578
+
579
+ res.json({
580
+ success: true,
581
+ apiKey: fullKey,
582
+ hint: keyHint,
583
+ environment,
584
+ message: 'Save this key — it will not be shown again',
585
+ });
586
+ } catch (error) {
587
+ console.error('[Developer] Error generating API key:', error.message);
588
+ res.status(500).json({ success: false, error: 'Failed to generate API key' });
589
+ }
590
+ });
591
+
592
+ /**
593
+ * DELETE /api/developer/keys/:keyId
594
+ * Revoke an API key
595
+ */
596
+ portalRouter.delete('/keys/:keyId', authenticate, async (req, res) => {
597
+ try {
598
+ const { keyId } = req.params;
599
+
600
+ const result = await pool.query(`
601
+ UPDATE developer_api_keys
602
+ SET is_active = FALSE
603
+ FROM developer_apps a
604
+ JOIN developer_accounts d ON a.developer_id = d.id
605
+ WHERE developer_api_keys.id = $1
606
+ AND developer_api_keys.app_id = a.id
607
+ AND d.wallet_address = $2
608
+ RETURNING developer_api_keys.id
609
+ `, [keyId, req.user.walletAddress]);
610
+
611
+ if (result.rows.length === 0) {
612
+ return res.status(404).json({ success: false, error: 'Key not found' });
613
+ }
614
+
615
+ res.json({ success: true, message: 'API key revoked' });
616
+ } catch (error) {
617
+ console.error('[Developer] Error revoking key:', error.message);
618
+ res.status(500).json({ success: false, error: 'Failed to revoke key' });
619
+ }
620
+ });
621
+
622
+ // ── Analytics / Earnings ──
623
+
624
+ /**
625
+ * GET /api/developer/stats
626
+ * Get developer stats (total games, total volume, earnings)
627
+ */
628
+ portalRouter.get('/stats', authenticate, async (req, res) => {
629
+ try {
630
+ const devResult = await pool.query(
631
+ 'SELECT id FROM developer_accounts WHERE wallet_address = $1',
632
+ [req.user.walletAddress]
633
+ );
634
+
635
+ if (devResult.rows.length === 0) {
636
+ return res.status(404).json({ success: false, error: 'Developer account not found' });
637
+ }
638
+
639
+ const developerId = devResult.rows[0].id;
640
+
641
+ // Total games attributed to this developer
642
+ const gamesResult = await pool.query(
643
+ 'SELECT COUNT(*) as total_games FROM developer_game_attributions WHERE developer_id = $1',
644
+ [developerId]
645
+ );
646
+
647
+ // Total API calls
648
+ const apiCallsResult = await pool.query(`
649
+ SELECT COUNT(*) as total_calls
650
+ FROM developer_api_logs l
651
+ JOIN developer_apps a ON l.app_id = a.id
652
+ WHERE a.developer_id = $1
653
+ `, [developerId]);
654
+
655
+ // Apps count
656
+ const appsResult = await pool.query(
657
+ "SELECT COUNT(*) as total_apps FROM developer_apps WHERE developer_id = $1 AND status = 'active'",
658
+ [developerId]
659
+ );
660
+
661
+ res.json({
662
+ success: true,
663
+ stats: {
664
+ totalGames: parseInt(gamesResult.rows[0].total_games),
665
+ totalApiCalls: parseInt(apiCallsResult.rows[0].total_calls),
666
+ totalApps: parseInt(appsResult.rows[0].total_apps),
667
+ }
668
+ });
669
+ } catch (error) {
670
+ console.error('[Developer] Error fetching stats:', error.message);
671
+ res.status(500).json({ success: false, error: 'Failed to fetch stats' });
672
+ }
673
+ });
674
+
675
+ /**
676
+ * GET /api/developer/apps/:appId/usage
677
+ * Get API usage stats for an app (last 30 days)
678
+ */
679
+ portalRouter.get('/apps/:appId/usage', authenticate, async (req, res) => {
680
+ try {
681
+ const { appId } = req.params;
682
+
683
+ // Verify ownership
684
+ const appResult = await pool.query(`
685
+ SELECT a.id FROM developer_apps a
686
+ JOIN developer_accounts d ON a.developer_id = d.id
687
+ WHERE a.id = $1 AND d.wallet_address = $2
688
+ `, [appId, req.user.walletAddress]);
689
+
690
+ if (appResult.rows.length === 0) {
691
+ return res.status(404).json({ success: false, error: 'App not found' });
692
+ }
693
+
694
+ // Daily API calls for last 30 days
695
+ const usageResult = await pool.query(`
696
+ SELECT
697
+ DATE(created_at) as date,
698
+ COUNT(*) as calls,
699
+ AVG(response_time_ms)::int as avg_response_ms
700
+ FROM developer_api_logs
701
+ WHERE app_id = $1 AND created_at > NOW() - INTERVAL '30 days'
702
+ GROUP BY DATE(created_at)
703
+ ORDER BY date DESC
704
+ `, [appId]);
705
+
706
+ // Games attributed to this app
707
+ const gamesResult = await pool.query(
708
+ 'SELECT COUNT(*) as total_games FROM developer_game_attributions WHERE app_id = $1',
709
+ [appId]
710
+ );
711
+
712
+ res.json({
713
+ success: true,
714
+ usage: usageResult.rows,
715
+ totalGames: parseInt(gamesResult.rows[0].total_games),
716
+ });
717
+ } catch (error) {
718
+ console.error('[Developer] Error fetching usage:', error.message);
719
+ res.status(500).json({ success: false, error: 'Failed to fetch usage stats' });
720
+ }
721
+ });
722
+
723
+
724
+ // ============================================================
725
+ // WEBHOOK MANAGEMENT ROUTES (Portal — JWT auth)
726
+ // ============================================================
727
+
728
+ /**
729
+ * POST /api/developer/apps/:appId/webhooks
730
+ * Register a new webhook for an app
731
+ */
732
+ portalRouter.post('/apps/:appId/webhooks', authenticate, async (req, res) => {
733
+ try {
734
+ const { appId } = req.params;
735
+ const { url, events, description } = req.body;
736
+
737
+ // Verify ownership
738
+ const appResult = await pool.query(
739
+ `SELECT da.id FROM developer_apps da
740
+ JOIN developer_accounts dac ON da.developer_id = dac.id
741
+ WHERE da.id = $1 AND dac.wallet_address = $2`,
742
+ [appId, req.user.walletAddress]
743
+ );
744
+ if (appResult.rows.length === 0) {
745
+ return res.status(404).json({ success: false, error: 'App not found' });
746
+ }
747
+
748
+ if (!url) return res.status(400).json({ success: false, error: 'url is required' });
749
+ if (!events || !Array.isArray(events) || events.length === 0) {
750
+ return res.status(400).json({ success: false, error: 'events array is required' });
751
+ }
752
+
753
+ // Validate URL (HTTPS or localhost for dev)
754
+ try {
755
+ const parsed = new URL(url);
756
+ if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
757
+ return res.status(400).json({ success: false, error: 'Webhook URL must use HTTPS (localhost allowed for development)' });
758
+ }
759
+ } catch {
760
+ return res.status(400).json({ success: false, error: 'Invalid URL' });
761
+ }
762
+
763
+ // Validate events
764
+ const invalid = events.filter(e => !ALLOWED_WEBHOOK_EVENTS.includes(e));
765
+ if (invalid.length > 0) {
766
+ return res.status(400).json({ success: false, error: `Invalid events: ${invalid.join(', ')}. Allowed: ${ALLOWED_WEBHOOK_EVENTS.join(', ')}` });
767
+ }
768
+
769
+ // Generate signing secret
770
+ const secret = crypto.randomBytes(32).toString('hex');
771
+
772
+ const result = await pool.query(
773
+ `INSERT INTO developer_webhooks (app_id, url, secret, events, description)
774
+ VALUES ($1, $2, $3, $4, $5)
775
+ RETURNING id, url, events, is_active, description, created_at`,
776
+ [appId, url, secret, events, description || null]
777
+ );
778
+
779
+ const webhook = result.rows[0];
780
+
781
+ res.json({
782
+ success: true,
783
+ webhook: {
784
+ id: webhook.id,
785
+ url: webhook.url,
786
+ events: webhook.events,
787
+ isActive: webhook.is_active,
788
+ description: webhook.description,
789
+ secret, // Shown only once on creation
790
+ createdAt: webhook.created_at,
791
+ },
792
+ message: 'Save the secret — it will not be shown again.',
793
+ });
794
+ } catch (error) {
795
+ console.error('[Developer] Error creating webhook:', error.message);
796
+ res.status(500).json({ success: false, error: 'Failed to create webhook' });
797
+ }
798
+ });
799
+
800
+ /**
801
+ * GET /api/developer/apps/:appId/webhooks
802
+ * List all webhooks for an app
803
+ */
804
+ portalRouter.get('/apps/:appId/webhooks', authenticate, async (req, res) => {
805
+ try {
806
+ const { appId } = req.params;
807
+
808
+ // Verify ownership
809
+ const appResult = await pool.query(
810
+ `SELECT da.id FROM developer_apps da
811
+ JOIN developer_accounts dac ON da.developer_id = dac.id
812
+ WHERE da.id = $1 AND dac.wallet_address = $2`,
813
+ [appId, req.user.walletAddress]
814
+ );
815
+ if (appResult.rows.length === 0) {
816
+ return res.status(404).json({ success: false, error: 'App not found' });
817
+ }
818
+
819
+ const result = await pool.query(
820
+ `SELECT id, url, events, is_active, description, secret, created_at, updated_at
821
+ FROM developer_webhooks WHERE app_id = $1 ORDER BY created_at DESC`,
822
+ [appId]
823
+ );
824
+
825
+ res.json({
826
+ success: true,
827
+ webhooks: result.rows.map(w => ({
828
+ id: w.id,
829
+ url: w.url,
830
+ events: w.events,
831
+ isActive: w.is_active,
832
+ description: w.description,
833
+ secretHint: '••••' + w.secret.slice(-4),
834
+ createdAt: w.created_at,
835
+ updatedAt: w.updated_at,
836
+ })),
837
+ });
838
+ } catch (error) {
839
+ console.error('[Developer] Error listing webhooks:', error.message);
840
+ res.status(500).json({ success: false, error: 'Failed to list webhooks' });
841
+ }
842
+ });
843
+
844
+ /**
845
+ * GET /api/developer/apps/:appId/users
846
+ * List users who have authenticated through this app, with SOL balances.
847
+ * Supports pagination (limit/offset) and search by username or wallet address.
848
+ */
849
+ portalRouter.get('/apps/:appId/users', authenticate, async (req, res) => {
850
+ try {
851
+ const { appId } = req.params;
852
+ const limit = Math.min(20, Math.max(1, parseInt(req.query.limit) || 20));
853
+ const offset = Math.max(0, parseInt(req.query.offset) || 0);
854
+ const search = (req.query.search || '').trim();
855
+
856
+ // Verify ownership and get app environment
857
+ const appResult = await pool.query(
858
+ `SELECT da.id, da.environment FROM developer_apps da
859
+ JOIN developer_accounts dac ON da.developer_id = dac.id
860
+ WHERE da.id = $1 AND dac.wallet_address = $2`,
861
+ [appId, req.user.walletAddress]
862
+ );
863
+ if (appResult.rows.length === 0) {
864
+ return res.status(404).json({ success: false, error: 'App not found' });
865
+ }
866
+
867
+ const network = appResult.rows[0].environment === 'production' ? 'mainnet-beta' : 'devnet';
868
+
869
+ // Build WHERE clause with optional search
870
+ const whereClauses = ['dau.developer_app_id = $1'];
871
+ const params = [appId];
872
+
873
+ if (search) {
874
+ params.push(`%${search}%`);
875
+ whereClauses.push(`(u.username ILIKE $${params.length} OR u.wallet_address ILIKE $${params.length})`);
876
+ }
877
+
878
+ const whereSQL = whereClauses.join(' AND ');
879
+
880
+ // Count total
881
+ const countResult = await pool.query(
882
+ `SELECT COUNT(*) FROM developer_app_users dau
883
+ JOIN users u ON dau.user_id = u.id
884
+ WHERE ${whereSQL}`,
885
+ params
886
+ );
887
+ const total = parseInt(countResult.rows[0].count);
888
+
889
+ // Stats: active in last 7 days, new in last 7 days
890
+ const statsResult = await pool.query(
891
+ `SELECT
892
+ COUNT(*) FILTER (WHERE dau.last_seen_at > NOW() - INTERVAL '7 days') AS active_7d,
893
+ COUNT(*) FILTER (WHERE dau.first_seen_at > NOW() - INTERVAL '7 days') AS new_7d
894
+ FROM developer_app_users dau
895
+ WHERE dau.developer_app_id = $1`,
896
+ [appId]
897
+ );
898
+
899
+ // Fetch page of users
900
+ const usersResult = await pool.query(`
901
+ SELECT
902
+ u.wallet_address,
903
+ u.username,
904
+ u.avatar,
905
+ u.created_at,
906
+ dau.first_seen_at,
907
+ dau.last_seen_at,
908
+ dau.device_info
909
+ FROM developer_app_users dau
910
+ JOIN users u ON dau.user_id = u.id
911
+ WHERE ${whereSQL}
912
+ ORDER BY dau.last_seen_at DESC
913
+ LIMIT $${params.length + 1} OFFSET $${params.length + 2}
914
+ `, [...params, limit, offset]);
915
+
916
+ // Enrich with SOL balances in parallel
917
+ const balanceResults = await Promise.allSettled(
918
+ usersResult.rows.map(u =>
919
+ portfolioService.getPortfolio(u.wallet_address, network)
920
+ )
921
+ );
922
+
923
+ const users = usersResult.rows.map((u, i) => {
924
+ const balResult = balanceResults[i];
925
+ const solBalance = balResult.status === 'fulfilled'
926
+ ? balResult.value.nativeBalance.balance
927
+ : null;
928
+
929
+ return {
930
+ walletAddress: u.wallet_address,
931
+ username: u.username,
932
+ avatar: u.avatar,
933
+ createdAt: u.created_at,
934
+ firstSeenAt: u.first_seen_at,
935
+ lastSeenAt: u.last_seen_at,
936
+ solBalance,
937
+ deviceInfo: u.device_info || null,
938
+ };
939
+ });
940
+
941
+ res.json({
942
+ success: true,
943
+ data: {
944
+ users,
945
+ stats: {
946
+ total,
947
+ active7d: parseInt(statsResult.rows[0].active_7d),
948
+ new7d: parseInt(statsResult.rows[0].new_7d),
949
+ },
950
+ pagination: { total, limit, offset },
951
+ },
952
+ });
953
+ } catch (error) {
954
+ console.error('[Developer] Error listing app users:', error.message);
955
+ res.status(500).json({ success: false, error: 'Failed to list app users' });
956
+ }
957
+ });
958
+
959
+ /**
960
+ * GET /api/developer/apps/:appId/users/:walletAddress
961
+ * Get detailed user info including full portfolio (SOL, tokens, NFTs).
962
+ */
963
+ portalRouter.get('/apps/:appId/users/:walletAddress', authenticate, async (req, res) => {
964
+ try {
965
+ const { appId, walletAddress } = req.params;
966
+
967
+ // Verify ownership and get app environment
968
+ const appResult = await pool.query(
969
+ `SELECT da.id, da.environment FROM developer_apps da
970
+ JOIN developer_accounts dac ON da.developer_id = dac.id
971
+ WHERE da.id = $1 AND dac.wallet_address = $2`,
972
+ [appId, req.user.walletAddress]
973
+ );
974
+ if (appResult.rows.length === 0) {
975
+ return res.status(404).json({ success: false, error: 'App not found' });
976
+ }
977
+
978
+ const network = appResult.rows[0].environment === 'production' ? 'mainnet-beta' : 'devnet';
979
+
980
+ // Fetch user + app relationship
981
+ const userResult = await pool.query(`
982
+ SELECT
983
+ u.id,
984
+ u.wallet_address,
985
+ u.username,
986
+ u.avatar,
987
+ u.created_at,
988
+ dau.first_seen_at,
989
+ dau.last_seen_at,
990
+ dau.device_info
991
+ FROM developer_app_users dau
992
+ JOIN users u ON dau.user_id = u.id
993
+ WHERE dau.developer_app_id = $1 AND u.wallet_address = $2
994
+ `, [appId, walletAddress]);
995
+
996
+ if (userResult.rows.length === 0) {
997
+ return res.status(404).json({ success: false, error: 'User not found in this app' });
998
+ }
999
+
1000
+ const u = userResult.rows[0];
1001
+
1002
+ // Fetch full portfolio
1003
+ let portfolio = null;
1004
+ try {
1005
+ portfolio = await portfolioService.getPortfolio(walletAddress, network);
1006
+ } catch (err) {
1007
+ console.error(`[Developer] Portfolio fetch failed for ${walletAddress}:`, err.message);
1008
+ }
1009
+
1010
+ // Fetch push tokens for this user+app
1011
+ let pushTokens = [];
1012
+ try {
1013
+ const pushResult = await pool.query(
1014
+ `SELECT token, platform, device_name, active, created_at
1015
+ FROM expo_push_tokens
1016
+ WHERE user_id = $1 AND developer_app_id = $2
1017
+ ORDER BY updated_at DESC`,
1018
+ [u.id, appId]
1019
+ );
1020
+ pushTokens = pushResult.rows.map(t => ({
1021
+ token: t.token,
1022
+ platform: t.platform,
1023
+ deviceName: t.device_name,
1024
+ active: t.active,
1025
+ createdAt: t.created_at,
1026
+ }));
1027
+ } catch (err) {
1028
+ console.error(`[Developer] Push tokens fetch failed for user ${u.id}:`, err.message);
1029
+ }
1030
+
1031
+ res.json({
1032
+ success: true,
1033
+ data: {
1034
+ user: {
1035
+ walletAddress: u.wallet_address,
1036
+ username: u.username,
1037
+ avatar: u.avatar,
1038
+ createdAt: u.created_at,
1039
+ firstSeenAt: u.first_seen_at,
1040
+ lastSeenAt: u.last_seen_at,
1041
+ },
1042
+ deviceInfo: u.device_info || null,
1043
+ pushTokens,
1044
+ portfolio: portfolio ? {
1045
+ solBalance: portfolio.nativeBalance.balance,
1046
+ solBalanceFormatted: portfolio.nativeBalance.balanceFormatted,
1047
+ tokens: portfolio.tokenBalances.map(t => ({
1048
+ mint: t.mint,
1049
+ symbol: t.symbol,
1050
+ name: t.name,
1051
+ balance: t.balance,
1052
+ balanceFormatted: t.balanceFormatted,
1053
+ decimals: t.decimals,
1054
+ logo: t.metadata?.logoURI || null,
1055
+ isToken2022: t.isTokenExtension,
1056
+ })),
1057
+ nfts: portfolio.nftBalances.map(n => ({
1058
+ mint: n.mint,
1059
+ name: n.name,
1060
+ symbol: n.symbol,
1061
+ logo: n.metadata?.logoURI || null,
1062
+ isToken2022: n.isTokenExtension,
1063
+ })),
1064
+ tokenCount: portfolio.fungibleTokenCount,
1065
+ nftCount: portfolio.nftCount,
1066
+ network,
1067
+ fetchTimeMs: portfolio.fetchTimeMs,
1068
+ fromCache: portfolio.fromCache,
1069
+ } : null,
1070
+ },
1071
+ });
1072
+ } catch (error) {
1073
+ console.error('[Developer] Error fetching user detail:', error.message);
1074
+ res.status(500).json({ success: false, error: 'Failed to fetch user details' });
1075
+ }
1076
+ });
1077
+
1078
+ /**
1079
+ * POST /api/developer/apps/:appId/users/:walletAddress/push-test
1080
+ * Send a test push notification to a user from the developer portal.
1081
+ */
1082
+ portalRouter.post('/apps/:appId/users/:walletAddress/push-test', authenticate, async (req, res) => {
1083
+ try {
1084
+ const { appId, walletAddress } = req.params;
1085
+ const { title, body } = req.body;
1086
+
1087
+ // Verify ownership
1088
+ const appResult = await pool.query(
1089
+ `SELECT da.id FROM developer_apps da
1090
+ JOIN developer_accounts dac ON da.developer_id = dac.id
1091
+ WHERE da.id = $1 AND dac.wallet_address = $2`,
1092
+ [appId, req.user.walletAddress]
1093
+ );
1094
+ if (appResult.rows.length === 0) {
1095
+ return res.status(404).json({ success: false, error: 'App not found' });
1096
+ }
1097
+
1098
+ // Get user ID
1099
+ const userResult = await pool.query(
1100
+ 'SELECT id FROM users WHERE wallet_address = $1',
1101
+ [walletAddress]
1102
+ );
1103
+ if (userResult.rows.length === 0) {
1104
+ return res.status(404).json({ success: false, error: 'User not found' });
1105
+ }
1106
+
1107
+ const result = await expoPushService.sendToUser(userResult.rows[0].id, {
1108
+ title: title || 'Test Notification',
1109
+ body: body || 'This is a test push from the Dubs developer dashboard',
1110
+ data: { type: 'test' },
1111
+ });
1112
+
1113
+ res.json({ success: true, data: result });
1114
+ } catch (error) {
1115
+ console.error('[Developer] Error sending test push:', error.message);
1116
+ res.status(500).json({ success: false, error: 'Failed to send test push' });
1117
+ }
1118
+ });
1119
+
1120
+ /**
1121
+ * GET /api/developer/apps/:appId/games
1122
+ * List games attributed to this app via developer_game_attributions.
1123
+ * Supports pagination, status filter, and search by title or game_id.
1124
+ */
1125
+ portalRouter.get('/apps/:appId/games', authenticate, async (req, res) => {
1126
+ try {
1127
+ const { appId } = req.params;
1128
+ const limit = Math.min(50, Math.max(1, parseInt(req.query.limit) || 20));
1129
+ const offset = Math.max(0, parseInt(req.query.offset) || 0);
1130
+ const status = req.query.status || ''; // open, locked, resolved
1131
+ const search = (req.query.search || '').trim();
1132
+
1133
+ // Verify ownership
1134
+ const appResult = await pool.query(
1135
+ `SELECT da.id FROM developer_apps da
1136
+ JOIN developer_accounts dac ON da.developer_id = dac.id
1137
+ WHERE da.id = $1 AND dac.wallet_address = $2`,
1138
+ [appId, req.user.walletAddress]
1139
+ );
1140
+ if (appResult.rows.length === 0) {
1141
+ return res.status(404).json({ success: false, error: 'App not found' });
1142
+ }
1143
+
1144
+ // Build WHERE clauses
1145
+ const whereClauses = ['dga.app_id = $1'];
1146
+ const params = [appId];
1147
+
1148
+ if (status) {
1149
+ // Map public status back to DB status
1150
+ const dbStatus = status === 'open' ? 'pending' : status;
1151
+ params.push(dbStatus);
1152
+ whereClauses.push(`g.automatic_status = $${params.length}`);
1153
+ }
1154
+
1155
+ if (search) {
1156
+ params.push(`%${search}%`);
1157
+ whereClauses.push(`(g.title ILIKE $${params.length} OR g.game_id ILIKE $${params.length})`);
1158
+ }
1159
+
1160
+ const whereSQL = whereClauses.join(' AND ');
1161
+
1162
+ // Count total
1163
+ const countResult = await pool.query(
1164
+ `SELECT COUNT(DISTINCT g.game_id) FROM developer_game_attributions dga
1165
+ JOIN games g ON dga.game_id = g.game_id
1166
+ WHERE ${whereSQL}`,
1167
+ params
1168
+ );
1169
+ const total = parseInt(countResult.rows[0].count);
1170
+
1171
+ // Stats: open, locked, resolved counts for this app
1172
+ const statsResult = await pool.query(
1173
+ `SELECT
1174
+ COUNT(DISTINCT g.game_id) AS total,
1175
+ COUNT(DISTINCT g.game_id) FILTER (WHERE g.automatic_status = 'pending') AS open,
1176
+ COUNT(DISTINCT g.game_id) FILTER (WHERE g.automatic_status = 'locked' OR g.automatic_status = 'in_progress') AS locked,
1177
+ COUNT(DISTINCT g.game_id) FILTER (WHERE g.automatic_status = 'resolved') AS resolved
1178
+ FROM developer_game_attributions dga
1179
+ JOIN games g ON dga.game_id = g.game_id
1180
+ WHERE dga.app_id = $1`,
1181
+ [appId]
1182
+ );
1183
+
1184
+ // Fetch page of games
1185
+ const gamesResult = await pool.query(`
1186
+ SELECT * FROM (
1187
+ SELECT DISTINCT ON (g.game_id)
1188
+ g.game_id, g.title, g.buy_in, g.game_mode,
1189
+ g.is_locked, g.is_resolved, g.automatic_status,
1190
+ g.total_pool, g.lock_timestamp, g.created_at,
1191
+ g.created_by, g.game_address,
1192
+ g.home_team_players, g.away_team_players, g.draw_team_players,
1193
+ g.sports_event, g.matchup_image_url, g.max_players,
1194
+ g.sports_event->'finalScore'->>'winner' as winner_side,
1195
+ (SELECT COUNT(*) FROM user_game_refs ugr WHERE ugr.game_id = g.game_id AND ugr.claimed_at IS NOT NULL) as claimed_count
1196
+ FROM developer_game_attributions dga
1197
+ JOIN games g ON dga.game_id = g.game_id
1198
+ WHERE ${whereSQL}
1199
+ ORDER BY g.game_id, g.created_at DESC
1200
+ ) deduped
1201
+ ORDER BY deduped.created_at DESC
1202
+ LIMIT $${params.length + 1} OFFSET $${params.length + 2}
1203
+ `, [...params, limit, offset]);
1204
+
1205
+ const games = gamesResult.rows.map(g => {
1206
+ const base = normalizeGameRow(g);
1207
+ const homePlayers = (g.home_team_players || []);
1208
+ const awayPlayers = (g.away_team_players || []);
1209
+ const drawPlayers = (g.draw_team_players || []);
1210
+ const playerCount = homePlayers.length + awayPlayers.length + drawPlayers.length;
1211
+
1212
+ // Compute claim status for resolved games
1213
+ let claimStatus = null;
1214
+ if (g.is_resolved) {
1215
+ const winnerSide = g.winner_side || null;
1216
+ const claimedCount = parseInt(g.claimed_count) || 0;
1217
+ let eligibleCount;
1218
+ if (!winnerSide) {
1219
+ // Refund — all players are eligible
1220
+ eligibleCount = playerCount;
1221
+ } else if (winnerSide === 'home') {
1222
+ eligibleCount = homePlayers.length;
1223
+ } else if (winnerSide === 'away') {
1224
+ eligibleCount = awayPlayers.length;
1225
+ } else if (winnerSide === 'draw') {
1226
+ eligibleCount = drawPlayers.length;
1227
+ } else {
1228
+ eligibleCount = playerCount;
1229
+ }
1230
+ claimStatus = { winnerSide, claimedCount, eligibleCount };
1231
+ }
1232
+
1233
+ return {
1234
+ ...base,
1235
+ gameAddress: g.game_address,
1236
+ createdBy: g.created_by,
1237
+ maxPlayers: g.max_players || 0,
1238
+ playerCount,
1239
+ claimStatus,
1240
+ };
1241
+ });
1242
+
1243
+ const s = statsResult.rows[0];
1244
+ res.json({
1245
+ success: true,
1246
+ data: {
1247
+ games,
1248
+ stats: {
1249
+ total: parseInt(s.total),
1250
+ open: parseInt(s.open),
1251
+ locked: parseInt(s.locked),
1252
+ resolved: parseInt(s.resolved),
1253
+ },
1254
+ pagination: { total, limit, offset },
1255
+ },
1256
+ });
1257
+ } catch (error) {
1258
+ console.error('[Developer] Error listing app games:', error.message);
1259
+ res.status(500).json({ success: false, error: 'Failed to list app games' });
1260
+ }
1261
+ });
1262
+
1263
+ /**
1264
+ * GET /api/developer/apps/:appId/games/:gameId
1265
+ * Get detailed game info including enriched player profiles, signatures, and pools.
1266
+ */
1267
+ portalRouter.get('/apps/:appId/games/:gameId', authenticate, async (req, res) => {
1268
+ try {
1269
+ const { appId, gameId } = req.params;
1270
+
1271
+ // Verify ownership
1272
+ const appResult = await pool.query(
1273
+ `SELECT da.id, da.environment FROM developer_apps da
1274
+ JOIN developer_accounts dac ON da.developer_id = dac.id
1275
+ WHERE da.id = $1 AND dac.wallet_address = $2`,
1276
+ [appId, req.user.walletAddress]
1277
+ );
1278
+ if (appResult.rows.length === 0) {
1279
+ return res.status(404).json({ success: false, error: 'App not found' });
1280
+ }
1281
+
1282
+ // Verify game belongs to this app
1283
+ const attrResult = await pool.query(
1284
+ `SELECT id FROM developer_game_attributions WHERE game_id = $1 AND app_id = $2`,
1285
+ [gameId, appId]
1286
+ );
1287
+ if (attrResult.rows.length === 0) {
1288
+ return res.status(404).json({ success: false, error: 'Game not found in this app' });
1289
+ }
1290
+
1291
+ // Fetch game
1292
+ const gameResult = await pool.query(`
1293
+ SELECT
1294
+ g.game_id, g.game_address, g.title, g.buy_in, g.game_mode,
1295
+ g.is_locked, g.is_resolved, g.automatic_status, g.lock_timestamp,
1296
+ g.home_team_players, g.away_team_players, g.draw_team_players,
1297
+ g.player_amounts, g.home_pool, g.away_pool, g.draw_pool, g.total_pool,
1298
+ g.sports_event, g.matchup_image_url, g.max_players,
1299
+ g.created_by, g.claim_signature, g.created_at, g.updated_at, g.completed_at,
1300
+ g.sports_event->'finalScore'->>'winner' as winner_side
1301
+ FROM games g WHERE g.game_id = $1
1302
+ `, [gameId]);
1303
+
1304
+ if (gameResult.rows.length === 0) {
1305
+ return res.status(404).json({ success: false, error: 'Game not found' });
1306
+ }
1307
+
1308
+ const game = gameResult.rows[0];
1309
+ const se = game.sports_event || {};
1310
+ const playerAmounts = game.player_amounts || {};
1311
+
1312
+ // Build players array with profiles + signatures from user_game_refs
1313
+ const allWallets = [
1314
+ ...(game.home_team_players || []).map(w => ({ wallet: w, team: 'home' })),
1315
+ ...(game.away_team_players || []).map(w => ({ wallet: w, team: 'away' })),
1316
+ ...(game.draw_team_players || []).map(w => ({ wallet: w, team: 'draw' })),
1317
+ ];
1318
+
1319
+ let players = [];
1320
+ if (allWallets.length > 0) {
1321
+ const uniqueWallets = [...new Set(allWallets.map(b => b.wallet))];
1322
+
1323
+ // Fetch user profiles
1324
+ const usersResult = await pool.query(
1325
+ `SELECT wallet_address, username, avatar FROM users WHERE wallet_address = ANY($1)`,
1326
+ [uniqueWallets]
1327
+ );
1328
+ const userMap = {};
1329
+ for (const u of usersResult.rows) {
1330
+ userMap[u.wallet_address] = u;
1331
+ }
1332
+
1333
+ // Fetch user_game_refs for signatures
1334
+ const refsResult = await pool.query(
1335
+ `SELECT wallet_address, role, team_choice, joined_at, my_signature, my_explorer_url,
1336
+ claim_signature, claim_explorer_url, amount_claimed
1337
+ FROM user_game_refs WHERE game_id = $1 AND wallet_address = ANY($2)`,
1338
+ [gameId, uniqueWallets]
1339
+ );
1340
+ const refMap = {};
1341
+ for (const r of refsResult.rows) {
1342
+ refMap[r.wallet_address] = r;
1343
+ }
1344
+
1345
+ players = allWallets.map(b => {
1346
+ const user = userMap[b.wallet] || {};
1347
+ const ref = refMap[b.wallet] || {};
1348
+ return {
1349
+ wallet: b.wallet,
1350
+ username: user.username || null,
1351
+ avatar: user.avatar || null,
1352
+ team: b.team,
1353
+ amount: parseFloat(playerAmounts[b.wallet]) || parseFloat(game.buy_in),
1354
+ role: ref.role || null,
1355
+ joinedAt: ref.joined_at || null,
1356
+ joinSignature: ref.my_signature || null,
1357
+ joinExplorerUrl: ref.my_explorer_url || null,
1358
+ claimSignature: ref.claim_signature || null,
1359
+ claimExplorerUrl: ref.claim_explorer_url || null,
1360
+ amountClaimed: ref.amount_claimed ? parseFloat(ref.amount_claimed) : null,
1361
+ };
1362
+ });
1363
+ }
1364
+
1365
+ res.json({
1366
+ success: true,
1367
+ data: {
1368
+ game: {
1369
+ gameId: game.game_id,
1370
+ gameAddress: game.game_address,
1371
+ title: game.title,
1372
+ buyIn: parseFloat(game.buy_in),
1373
+ gameMode: game.game_mode,
1374
+ isLocked: game.is_locked,
1375
+ isResolved: game.is_resolved,
1376
+ status: publicGameStatus(game.automatic_status),
1377
+ league: se.strLeague || null,
1378
+ lockTimestamp: game.lock_timestamp,
1379
+ maxPlayers: game.max_players || 0,
1380
+ createdBy: game.created_by,
1381
+ resolveSignature: game.claim_signature || null,
1382
+ completedAt: game.completed_at || null,
1383
+ opponents: [
1384
+ { name: se.strHomeTeam || null, imageUrl: se.strHomeTeamBadge || null },
1385
+ { name: se.strAwayTeam || null, imageUrl: se.strAwayTeamBadge || null },
1386
+ ],
1387
+ pools: {
1388
+ home: parseFloat(game.home_pool) || 0,
1389
+ away: parseFloat(game.away_pool) || 0,
1390
+ draw: parseFloat(game.draw_pool) || 0,
1391
+ total: parseFloat(game.total_pool) || 0,
1392
+ },
1393
+ media: {
1394
+ poster: game.matchup_image_url || se.strPoster || null,
1395
+ thumbnail: game.matchup_image_url || se.strThumb || null,
1396
+ },
1397
+ winnerSide: game.winner_side || null,
1398
+ players,
1399
+ createdAt: game.created_at,
1400
+ updatedAt: game.updated_at,
1401
+ },
1402
+ },
1403
+ });
1404
+ } catch (error) {
1405
+ console.error('[Developer] Error fetching game detail:', error.message);
1406
+ res.status(500).json({ success: false, error: 'Failed to fetch game details' });
1407
+ }
1408
+ });
1409
+
1410
+ /**
1411
+ * POST /api/developer/apps/:appId/games/:gameId/resolve
1412
+ * Manually resolve a custom game (game_mode=6) from the developer portal.
1413
+ * Used when SDK resolution fails (e.g., network error) and game is stuck.
1414
+ */
1415
+ portalRouter.post('/apps/:appId/games/:gameId/resolve', authenticate, async (req, res) => {
1416
+ try {
1417
+ const { appId, gameId } = req.params;
1418
+ const { winner } = req.body; // 'home' | 'away' | 'draw' | null
1419
+
1420
+ // Validate winner value
1421
+ if (winner !== null && winner !== undefined && !['home', 'away', 'draw'].includes(winner)) {
1422
+ return res.status(400).json({ success: false, error: 'Invalid winner value. Must be "home", "away", "draw", or null (refund).' });
1423
+ }
1424
+
1425
+ // Verify developer owns the app
1426
+ const appResult = await pool.query(
1427
+ `SELECT da.id, da.environment FROM developer_apps da
1428
+ JOIN developer_accounts dac ON da.developer_id = dac.id
1429
+ WHERE da.id = $1 AND dac.wallet_address = $2`,
1430
+ [appId, req.user.walletAddress]
1431
+ );
1432
+ if (appResult.rows.length === 0) {
1433
+ return res.status(404).json({ success: false, error: 'App not found' });
1434
+ }
1435
+
1436
+ // Verify game belongs to this app
1437
+ const attrResult = await pool.query(
1438
+ `SELECT id FROM developer_game_attributions WHERE game_id = $1 AND app_id = $2`,
1439
+ [gameId, appId]
1440
+ );
1441
+ if (attrResult.rows.length === 0) {
1442
+ return res.status(404).json({ success: false, error: 'Game not found in this app' });
1443
+ }
1444
+
1445
+ // Fetch game and validate
1446
+ const gameResult = await pool.query(
1447
+ `SELECT game_id, game_mode, is_locked, is_resolved,
1448
+ home_team_players, away_team_players, draw_team_players
1449
+ FROM games WHERE game_id = $1`,
1450
+ [gameId]
1451
+ );
1452
+ if (gameResult.rows.length === 0) {
1453
+ return res.status(404).json({ success: false, error: 'Game not found' });
1454
+ }
1455
+
1456
+ const game = gameResult.rows[0];
1457
+
1458
+ if (game.game_mode !== 6) {
1459
+ return res.status(400).json({ success: false, error: 'Only custom games (game_mode=6) can be resolved via this endpoint' });
1460
+ }
1461
+ if (game.is_resolved) {
1462
+ return res.status(409).json({ success: false, error: 'Game has already been resolved' });
1463
+ }
1464
+
1465
+ // Auto-lock if not locked yet
1466
+ if (!game.is_locked) {
1467
+ await pool.query(
1468
+ `UPDATE games SET is_locked = true, automatic_status = 'locked', updated_at = NOW() WHERE game_id = $1`,
1469
+ [gameId]
1470
+ );
1471
+ console.log(`[Portal] Auto-locked game ${gameId} before resolution`);
1472
+ }
1473
+
1474
+ // Check for competition — if only one side has bets, force refund
1475
+ const homePlayers = game.home_team_players || [];
1476
+ const awayPlayers = game.away_team_players || [];
1477
+ const drawPlayers = game.draw_team_players || [];
1478
+ const sidesWithBets = [homePlayers.length > 0, awayPlayers.length > 0, drawPlayers.length > 0].filter(Boolean).length;
1479
+
1480
+ let effectiveWinner = winner ?? null;
1481
+ if (sidesWithBets < 2) {
1482
+ effectiveWinner = null;
1483
+ console.log(`[Portal] Game ${gameId} has only ${sidesWithBets} side(s) with bets — forcing refund`);
1484
+ }
1485
+
1486
+ // Resolve on-chain + update DB
1487
+ const resolver = getCustomGameResolver();
1488
+ const { signature: txSignature } = await resolver.resolveGame(gameId, effectiveWinner);
1489
+
1490
+ // Fire webhook notification
1491
+ fireWebhooks(appId, 'game.resolved', {
1492
+ gameId,
1493
+ winner: effectiveWinner,
1494
+ signature: txSignature,
1495
+ });
1496
+
1497
+ console.log(`[Portal] Game ${gameId} resolved by developer ${req.user.walletAddress} — winner: ${effectiveWinner}, tx: ${txSignature}`);
1498
+
1499
+ res.json({
1500
+ success: true,
1501
+ gameId,
1502
+ winner: effectiveWinner,
1503
+ signature: txSignature,
1504
+ explorerUrl: `https://solscan.io/tx/${txSignature}`,
1505
+ });
1506
+ } catch (error) {
1507
+ console.error('[Portal] Error resolving game:', error.message);
1508
+
1509
+ // Try to extract a human-readable message from Solana program errors
1510
+ let userMessage = error.message;
1511
+
1512
+ // Check for custom program error hex code in the message (e.g. 0x179b)
1513
+ const hexMatch = error.message.match(/custom program error: 0x([0-9a-fA-F]+)/);
1514
+ if (hexMatch) {
1515
+ const errorCode = parseInt(hexMatch[1], 16);
1516
+ const known = SOLANA_PROGRAM_ERRORS[errorCode];
1517
+ if (known) {
1518
+ userMessage = known.message;
1519
+ }
1520
+ }
1521
+
1522
+ // Also try the structured error if available (SendTransactionError)
1523
+ if (error.transactionError) {
1524
+ const parsed = parseSolanaError(error.transactionError);
1525
+ if (parsed && parsed.code !== 'unknown_error') {
1526
+ userMessage = parsed.message;
1527
+ }
1528
+ }
1529
+
1530
+ res.status(500).json({ success: false, error: userMessage });
1531
+ }
1532
+ });
1533
+
1534
+ /**
1535
+ * PATCH /api/developer/webhooks/:webhookId
1536
+ * Update a webhook
1537
+ */
1538
+ portalRouter.patch('/webhooks/:webhookId', authenticate, async (req, res) => {
1539
+ try {
1540
+ const { webhookId } = req.params;
1541
+ const { url, events, isActive, description } = req.body;
1542
+
1543
+ // Verify ownership
1544
+ const hookResult = await pool.query(
1545
+ `SELECT dw.id FROM developer_webhooks dw
1546
+ JOIN developer_apps da ON dw.app_id = da.id
1547
+ JOIN developer_accounts dac ON da.developer_id = dac.id
1548
+ WHERE dw.id = $1 AND dac.wallet_address = $2`,
1549
+ [webhookId, req.user.walletAddress]
1550
+ );
1551
+ if (hookResult.rows.length === 0) {
1552
+ return res.status(404).json({ success: false, error: 'Webhook not found' });
1553
+ }
1554
+
1555
+ if (events) {
1556
+ const invalid = events.filter(e => !ALLOWED_WEBHOOK_EVENTS.includes(e));
1557
+ if (invalid.length > 0) {
1558
+ return res.status(400).json({ success: false, error: `Invalid events: ${invalid.join(', ')}` });
1559
+ }
1560
+ }
1561
+
1562
+ if (url) {
1563
+ try {
1564
+ const parsed = new URL(url);
1565
+ if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
1566
+ return res.status(400).json({ success: false, error: 'Webhook URL must use HTTPS' });
1567
+ }
1568
+ } catch {
1569
+ return res.status(400).json({ success: false, error: 'Invalid URL' });
1570
+ }
1571
+ }
1572
+
1573
+ const updates = [];
1574
+ const params = [];
1575
+ if (url !== undefined) { params.push(url); updates.push(`url = $${params.length}`); }
1576
+ if (events !== undefined) { params.push(events); updates.push(`events = $${params.length}`); }
1577
+ if (isActive !== undefined) { params.push(isActive); updates.push(`is_active = $${params.length}`); }
1578
+ if (description !== undefined) { params.push(description); updates.push(`description = $${params.length}`); }
1579
+
1580
+ if (updates.length === 0) {
1581
+ return res.status(400).json({ success: false, error: 'No fields to update' });
1582
+ }
1583
+
1584
+ updates.push('updated_at = NOW()');
1585
+ params.push(webhookId);
1586
+
1587
+ const result = await pool.query(
1588
+ `UPDATE developer_webhooks SET ${updates.join(', ')} WHERE id = $${params.length}
1589
+ RETURNING id, url, events, is_active, description, updated_at`,
1590
+ params
1591
+ );
1592
+
1593
+ const w = result.rows[0];
1594
+ res.json({
1595
+ success: true,
1596
+ webhook: {
1597
+ id: w.id,
1598
+ url: w.url,
1599
+ events: w.events,
1600
+ isActive: w.is_active,
1601
+ description: w.description,
1602
+ updatedAt: w.updated_at,
1603
+ },
1604
+ });
1605
+ } catch (error) {
1606
+ console.error('[Developer] Error updating webhook:', error.message);
1607
+ res.status(500).json({ success: false, error: 'Failed to update webhook' });
1608
+ }
1609
+ });
1610
+
1611
+ /**
1612
+ * DELETE /api/developer/webhooks/:webhookId
1613
+ * Delete a webhook (cascade removes logs)
1614
+ */
1615
+ portalRouter.delete('/webhooks/:webhookId', authenticate, async (req, res) => {
1616
+ try {
1617
+ const { webhookId } = req.params;
1618
+
1619
+ // Verify ownership
1620
+ const hookResult = await pool.query(
1621
+ `SELECT dw.id FROM developer_webhooks dw
1622
+ JOIN developer_apps da ON dw.app_id = da.id
1623
+ JOIN developer_accounts dac ON da.developer_id = dac.id
1624
+ WHERE dw.id = $1 AND dac.wallet_address = $2`,
1625
+ [webhookId, req.user.walletAddress]
1626
+ );
1627
+ if (hookResult.rows.length === 0) {
1628
+ return res.status(404).json({ success: false, error: 'Webhook not found' });
1629
+ }
1630
+
1631
+ await pool.query('DELETE FROM developer_webhooks WHERE id = $1', [webhookId]);
1632
+
1633
+ res.json({ success: true, message: 'Webhook deleted' });
1634
+ } catch (error) {
1635
+ console.error('[Developer] Error deleting webhook:', error.message);
1636
+ res.status(500).json({ success: false, error: 'Failed to delete webhook' });
1637
+ }
1638
+ });
1639
+
1640
+ /**
1641
+ * GET /api/developer/webhooks/:webhookId/logs
1642
+ * Recent delivery logs for a webhook
1643
+ */
1644
+ portalRouter.get('/webhooks/:webhookId/logs', authenticate, async (req, res) => {
1645
+ try {
1646
+ const { webhookId } = req.params;
1647
+
1648
+ // Verify ownership
1649
+ const hookResult = await pool.query(
1650
+ `SELECT dw.id FROM developer_webhooks dw
1651
+ JOIN developer_apps da ON dw.app_id = da.id
1652
+ JOIN developer_accounts dac ON da.developer_id = dac.id
1653
+ WHERE dw.id = $1 AND dac.wallet_address = $2`,
1654
+ [webhookId, req.user.walletAddress]
1655
+ );
1656
+ if (hookResult.rows.length === 0) {
1657
+ return res.status(404).json({ success: false, error: 'Webhook not found' });
1658
+ }
1659
+
1660
+ const result = await pool.query(
1661
+ `SELECT id, event, status_code, attempts, success, error, created_at
1662
+ FROM developer_webhook_logs
1663
+ WHERE webhook_id = $1
1664
+ ORDER BY created_at DESC
1665
+ LIMIT 50`,
1666
+ [webhookId]
1667
+ );
1668
+
1669
+ res.json({
1670
+ success: true,
1671
+ logs: result.rows.map(l => ({
1672
+ id: l.id,
1673
+ event: l.event,
1674
+ statusCode: l.status_code,
1675
+ attempts: l.attempts,
1676
+ success: l.success,
1677
+ error: l.error,
1678
+ createdAt: l.created_at,
1679
+ })),
1680
+ });
1681
+ } catch (error) {
1682
+ console.error('[Developer] Error fetching webhook logs:', error.message);
1683
+ res.status(500).json({ success: false, error: 'Failed to fetch webhook logs' });
1684
+ }
1685
+ });
1686
+
1687
+ // ============================================================
1688
+ // PUBLIC API ROUTES — API key auth (third-party developer usage)
1689
+ // ============================================================
1690
+
1691
+ // All routes here require apiKeyAuth middleware
1692
+ apiRouter.use(apiKeyAuth);
1693
+
1694
+ // Logging middleware — track all API calls
1695
+ apiRouter.use((req, res, next) => {
1696
+ const start = Date.now();
1697
+ res.on('finish', () => {
1698
+ logApiCall(req, res.statusCode, Date.now() - start);
1699
+ });
1700
+ next();
1701
+ });
1702
+
1703
+ // ============================================================
1704
+ // USER AUTH ROUTES — API key + optional user JWT
1705
+ // ============================================================
1706
+
1707
+ /**
1708
+ * UPSERT into developer_app_users junction table.
1709
+ * Tracks which users authenticated through which developer app.
1710
+ */
1711
+ async function trackAppUser(appId, userId, deviceInfo = null) {
1712
+ await pool.query(`
1713
+ INSERT INTO developer_app_users (developer_app_id, user_id, first_seen_at, last_seen_at, device_info)
1714
+ VALUES ($1, $2, NOW(), NOW(), $3)
1715
+ ON CONFLICT (developer_app_id, user_id)
1716
+ DO UPDATE SET last_seen_at = NOW(), device_info = COALESCE($3, developer_app_users.device_info)
1717
+ `, [appId, userId, deviceInfo ? JSON.stringify(deviceInfo) : null]);
1718
+ }
1719
+
1720
+ /**
1721
+ * Generate an 8-char referral code (same algorithm as authRoutes.js).
1722
+ * Excludes confusing chars: 0, O, I, 1.
1723
+ */
1724
+ async function generateUniqueReferralCode() {
1725
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
1726
+ const maxAttempts = 10;
1727
+
1728
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1729
+ let code = '';
1730
+ for (let i = 0; i < 8; i++) {
1731
+ code += chars.charAt(Math.floor(Math.random() * chars.length));
1732
+ }
1733
+ const existing = await pool.query('SELECT id FROM users WHERE my_referral_code = $1', [code]);
1734
+ if (existing.rows.length === 0) return code;
1735
+ }
1736
+
1737
+ return null;
1738
+ }
1739
+
1740
+ /**
1741
+ * POST /api/developer/v1/auth/nonce
1742
+ * Generate a nonce for wallet signature verification.
1743
+ * Requires: API key only
1744
+ */
1745
+ apiRouter.post('/auth/nonce', async (req, res) => {
1746
+ try {
1747
+ const { walletAddress } = req.body;
1748
+
1749
+ if (!walletAddress) {
1750
+ return apiError(res, 400, 'invalid_request', 'walletAddress is required');
1751
+ }
1752
+
1753
+ // Validate wallet format
1754
+ try {
1755
+ new PublicKey(walletAddress);
1756
+ } catch {
1757
+ return apiError(res, 400, 'invalid_wallet', 'Invalid Solana wallet address');
1758
+ }
1759
+
1760
+ const nonce = crypto.randomBytes(32).toString('hex');
1761
+
1762
+ await pool.query(
1763
+ `INSERT INTO auth_nonces (wallet_address, nonce, expires_at, used)
1764
+ VALUES ($1, $2, NOW() + INTERVAL '5 minutes', false)
1765
+ ON CONFLICT (wallet_address)
1766
+ DO UPDATE SET nonce = $2, expires_at = NOW() + INTERVAL '5 minutes', used = false`,
1767
+ [walletAddress, nonce]
1768
+ );
1769
+
1770
+ const message = `Sign in to ${req.developerApp.appName} via Dubs\nNonce: ${nonce}`;
1771
+
1772
+ res.json({
1773
+ success: true,
1774
+ data: { nonce, message },
1775
+ });
1776
+ } catch (error) {
1777
+ console.error('[DevAPI] Error generating nonce:', error.message);
1778
+ apiError(res, 500, 'internal_error', 'Failed to generate nonce');
1779
+ }
1780
+ });
1781
+
1782
+ /**
1783
+ * POST /api/developer/v1/auth/authenticate
1784
+ * Verify wallet signature. Returns JWT+profile for existing users, or needsRegistration for new wallets.
1785
+ * Requires: API key only
1786
+ */
1787
+ apiRouter.post('/auth/authenticate', async (req, res) => {
1788
+ try {
1789
+ const { walletAddress, signature, nonce, deviceInfo } = req.body;
1790
+
1791
+ if (!walletAddress || !signature || !nonce) {
1792
+ return apiError(res, 400, 'invalid_request', 'walletAddress, signature, and nonce are required');
1793
+ }
1794
+
1795
+ // Validate nonce
1796
+ const nonceResult = await pool.query(
1797
+ 'SELECT * FROM auth_nonces WHERE wallet_address = $1 AND nonce = $2 AND used = false AND expires_at > NOW()',
1798
+ [walletAddress, nonce]
1799
+ );
1800
+
1801
+ if (nonceResult.rows.length === 0) {
1802
+ return apiError(res, 400, 'invalid_nonce', 'Invalid or expired nonce');
1803
+ }
1804
+
1805
+ // Reconstruct message from nonce + appName (never trust client message)
1806
+ const message = `Sign in to ${req.developerApp.appName} via Dubs\nNonce: ${nonce}`;
1807
+ const messageBytes = new TextEncoder().encode(message);
1808
+ const signatureBytes = bs58.decode(signature);
1809
+ const publicKeyBytes = new PublicKey(walletAddress).toBytes();
1810
+
1811
+ const valid = nacl.sign.detached.verify(messageBytes, signatureBytes, publicKeyBytes);
1812
+
1813
+ if (!valid) {
1814
+ return apiError(res, 401, 'invalid_signature', 'Signature verification failed');
1815
+ }
1816
+
1817
+ // Check if user exists
1818
+ const userResult = await pool.query(
1819
+ 'SELECT * FROM users WHERE wallet_address = $1',
1820
+ [walletAddress]
1821
+ );
1822
+
1823
+ if (userResult.rows.length === 0) {
1824
+ // New wallet — do NOT mark nonce as used (register will consume it)
1825
+ return res.json({
1826
+ success: true,
1827
+ data: { needsRegistration: true },
1828
+ });
1829
+ }
1830
+
1831
+ // Existing user — mark nonce as used
1832
+ await pool.query(
1833
+ 'UPDATE auth_nonces SET used = true WHERE wallet_address = $1 AND nonce = $2',
1834
+ [walletAddress, nonce]
1835
+ );
1836
+
1837
+ const user = userResult.rows[0];
1838
+ const token = generateToken(walletAddress, user.id);
1839
+ const expiresAt = new Date(Date.now() + parseDuration(JWT_EXPIRES_IN));
1840
+ await createSession(walletAddress, user.id, token, expiresAt);
1841
+
1842
+ // Track app-user relationship
1843
+ await trackAppUser(req.developerApp.appId, user.id, deviceInfo);
1844
+
1845
+ res.json({
1846
+ success: true,
1847
+ data: {
1848
+ needsRegistration: false,
1849
+ user: {
1850
+ walletAddress: user.wallet_address,
1851
+ username: user.username,
1852
+ avatar: user.avatar,
1853
+ myReferralCode: user.my_referral_code,
1854
+ onboardingComplete: user.onboarding_complete,
1855
+ createdAt: user.created_at,
1856
+ },
1857
+ token,
1858
+ },
1859
+ });
1860
+ } catch (error) {
1861
+ console.error('[DevAPI] Error authenticating:', error.message);
1862
+ apiError(res, 500, 'internal_error', 'Failed to authenticate');
1863
+ }
1864
+ });
1865
+
1866
+ /**
1867
+ * POST /api/developer/v1/auth/register
1868
+ * Register a new user with wallet + username. Requires valid nonce+signature.
1869
+ * Requires: API key only
1870
+ */
1871
+ apiRouter.post('/auth/register', async (req, res) => {
1872
+ try {
1873
+ const { walletAddress, signature, nonce, username, referralCode, avatarUrl, deviceInfo } = req.body;
1874
+
1875
+ if (!walletAddress || !signature || !nonce || !username) {
1876
+ return apiError(res, 400, 'invalid_request', 'walletAddress, signature, nonce, and username are required');
1877
+ }
1878
+
1879
+ // Validate nonce
1880
+ const nonceResult = await pool.query(
1881
+ 'SELECT * FROM auth_nonces WHERE wallet_address = $1 AND nonce = $2 AND used = false AND expires_at > NOW()',
1882
+ [walletAddress, nonce]
1883
+ );
1884
+
1885
+ if (nonceResult.rows.length === 0) {
1886
+ return apiError(res, 400, 'invalid_nonce', 'Invalid or expired nonce');
1887
+ }
1888
+
1889
+ // Verify signature
1890
+ const message = `Sign in to ${req.developerApp.appName} via Dubs\nNonce: ${nonce}`;
1891
+ const messageBytes = new TextEncoder().encode(message);
1892
+ const signatureBytes = bs58.decode(signature);
1893
+ const publicKeyBytes = new PublicKey(walletAddress).toBytes();
1894
+
1895
+ const valid = nacl.sign.detached.verify(messageBytes, signatureBytes, publicKeyBytes);
1896
+
1897
+ if (!valid) {
1898
+ return apiError(res, 401, 'invalid_signature', 'Signature verification failed');
1899
+ }
1900
+
1901
+ // Check wallet not already registered
1902
+ const existingWallet = await pool.query('SELECT id FROM users WHERE wallet_address = $1', [walletAddress]);
1903
+ if (existingWallet.rows.length > 0) {
1904
+ return apiError(res, 409, 'already_registered', 'This wallet is already registered');
1905
+ }
1906
+
1907
+ // Username validation: 3-20 chars, alphanumeric + underscores
1908
+ if (username.length < 3) {
1909
+ return apiError(res, 400, 'invalid_username', 'Username must be at least 3 characters');
1910
+ }
1911
+ if (username.length > 20) {
1912
+ return apiError(res, 400, 'invalid_username', 'Username must be 20 characters or less');
1913
+ }
1914
+ if (!/^[a-zA-Z0-9_]+$/.test(username)) {
1915
+ return apiError(res, 400, 'invalid_username', 'Username can only contain letters, numbers, and underscores');
1916
+ }
1917
+
1918
+ // Case-insensitive uniqueness check
1919
+ const existingUsername = await pool.query(
1920
+ 'SELECT id FROM users WHERE LOWER(username) = LOWER($1)',
1921
+ [username]
1922
+ );
1923
+ if (existingUsername.rows.length > 0) {
1924
+ return apiError(res, 409, 'username_taken', 'Username is already taken');
1925
+ }
1926
+
1927
+ // Mark nonce as used
1928
+ await pool.query(
1929
+ 'UPDATE auth_nonces SET used = true WHERE wallet_address = $1 AND nonce = $2',
1930
+ [walletAddress, nonce]
1931
+ );
1932
+
1933
+ // Insert user
1934
+ const insertResult = await pool.query(
1935
+ `INSERT INTO users (wallet_address, username, avatar, referral_code, created_at, onboarding_complete)
1936
+ VALUES ($1, $2, $3, $4, NOW(), false)
1937
+ RETURNING *`,
1938
+ [walletAddress, username, avatarUrl || null, referralCode || null]
1939
+ );
1940
+
1941
+ let user = insertResult.rows[0];
1942
+
1943
+ // Generate referral code (same algo as authRoutes.js)
1944
+ const myReferralCode = await generateUniqueReferralCode();
1945
+ if (myReferralCode) {
1946
+ const updateResult = await pool.query(
1947
+ 'UPDATE users SET my_referral_code = $1 WHERE id = $2 RETURNING *',
1948
+ [myReferralCode, user.id]
1949
+ );
1950
+ user = updateResult.rows[0];
1951
+ }
1952
+
1953
+ // Generate JWT + create session
1954
+ const token = generateToken(walletAddress, user.id);
1955
+ const expiresAt = new Date(Date.now() + parseDuration(JWT_EXPIRES_IN));
1956
+ await createSession(walletAddress, user.id, token, expiresAt);
1957
+
1958
+ // Track app-user relationship
1959
+ await trackAppUser(req.developerApp.appId, user.id, deviceInfo);
1960
+
1961
+ console.log(`[DevAPI] New user registered: ${username} (${walletAddress}) via app ${req.developerApp.appName}`);
1962
+
1963
+ res.json({
1964
+ success: true,
1965
+ data: {
1966
+ user: {
1967
+ walletAddress: user.wallet_address,
1968
+ username: user.username,
1969
+ avatar: user.avatar,
1970
+ myReferralCode: user.my_referral_code,
1971
+ onboardingComplete: user.onboarding_complete,
1972
+ createdAt: user.created_at,
1973
+ },
1974
+ token,
1975
+ },
1976
+ });
1977
+ } catch (error) {
1978
+ console.error('[DevAPI] Error registering user:', error.message);
1979
+ apiError(res, 500, 'internal_error', 'Failed to register user');
1980
+ }
1981
+ });
1982
+
1983
+ /**
1984
+ * GET /api/developer/v1/auth/me
1985
+ * Get the authenticated user's profile.
1986
+ * Requires: API key + user JWT (dual auth)
1987
+ */
1988
+ apiRouter.get('/auth/me', developerUserAuth, async (req, res) => {
1989
+ try {
1990
+ const userResult = await pool.query(
1991
+ 'SELECT * FROM users WHERE wallet_address = $1',
1992
+ [req.developerUser.walletAddress]
1993
+ );
1994
+
1995
+ if (userResult.rows.length === 0) {
1996
+ return apiError(res, 404, 'user_not_found', 'User not found');
1997
+ }
1998
+
1999
+ const user = userResult.rows[0];
2000
+
2001
+ res.json({
2002
+ success: true,
2003
+ data: {
2004
+ user: {
2005
+ walletAddress: user.wallet_address,
2006
+ username: user.username,
2007
+ avatar: user.avatar,
2008
+ myReferralCode: user.my_referral_code,
2009
+ onboardingComplete: user.onboarding_complete,
2010
+ createdAt: user.created_at,
2011
+ },
2012
+ },
2013
+ });
2014
+ } catch (error) {
2015
+ console.error('[DevAPI] Error fetching user profile:', error.message);
2016
+ apiError(res, 500, 'internal_error', 'Failed to fetch user profile');
2017
+ }
2018
+ });
2019
+
2020
+ /**
2021
+ * PATCH /api/developer/v1/auth/profile
2022
+ * Update the authenticated user's profile (e.g. avatar).
2023
+ * Requires: API key + user JWT (dual auth)
2024
+ */
2025
+ apiRouter.patch('/auth/profile', developerUserAuth, async (req, res) => {
2026
+ try {
2027
+ const { avatar } = req.body;
2028
+
2029
+ if (avatar !== undefined && typeof avatar !== 'string') {
2030
+ return apiError(res, 400, 'invalid_request', 'avatar must be a string URL');
2031
+ }
2032
+
2033
+ const result = await pool.query(
2034
+ `UPDATE users
2035
+ SET avatar = COALESCE($2, avatar),
2036
+ updated_at = NOW()
2037
+ WHERE id = $1
2038
+ RETURNING wallet_address, username, avatar, created_at`,
2039
+ [req.developerUser.userId, avatar]
2040
+ );
2041
+
2042
+ if (result.rows.length === 0) {
2043
+ return apiError(res, 404, 'user_not_found', 'User not found');
2044
+ }
2045
+
2046
+ const user = result.rows[0];
2047
+ res.json({
2048
+ success: true,
2049
+ data: {
2050
+ user: {
2051
+ walletAddress: user.wallet_address,
2052
+ username: user.username,
2053
+ avatar: user.avatar,
2054
+ createdAt: user.created_at,
2055
+ },
2056
+ },
2057
+ });
2058
+ } catch (error) {
2059
+ console.error('[DevAPI] Error updating user profile:', error.message);
2060
+ apiError(res, 500, 'internal_error', 'Failed to update user profile');
2061
+ }
2062
+ });
2063
+
2064
+ /**
2065
+ * POST /api/developer/v1/auth/logout
2066
+ * Log out the authenticated user (delete session).
2067
+ * Requires: API key + user JWT (dual auth)
2068
+ */
2069
+ apiRouter.post('/auth/logout', developerUserAuth, async (req, res) => {
2070
+ try {
2071
+ const authHeader = req.headers.authorization;
2072
+ const token = authHeader.substring(7);
2073
+
2074
+ await deleteSession(req.developerUser.walletAddress, token);
2075
+
2076
+ res.json({
2077
+ success: true,
2078
+ data: { message: 'Logged out successfully' },
2079
+ });
2080
+ } catch (error) {
2081
+ console.error('[DevAPI] Error logging out:', error.message);
2082
+ apiError(res, 500, 'internal_error', 'Failed to log out');
2083
+ }
2084
+ });
2085
+
2086
+ // ── Push Notification Endpoints ──
2087
+
2088
+ /**
2089
+ * POST /api/developer/v1/push/expo-token
2090
+ * Register an Expo push token for the authenticated user.
2091
+ * Requires: API key + user JWT (dual auth)
2092
+ */
2093
+ apiRouter.post('/push/expo-token', developerUserAuth, async (req, res) => {
2094
+ try {
2095
+ const { token, platform, deviceName } = req.body;
2096
+ if (!token || !platform) {
2097
+ return apiError(res, 400, 'missing_params', 'token and platform are required');
2098
+ }
2099
+
2100
+ const appId = req.developerApp.appId;
2101
+ const userId = req.developerUser.userId;
2102
+
2103
+ const result = await expoPushService.registerToken(userId, token, platform, deviceName, appId);
2104
+
2105
+ res.json({
2106
+ success: true,
2107
+ data: { tokenId: result.id, token: result.token },
2108
+ });
2109
+ } catch (error) {
2110
+ console.error('[DevAPI] Error registering push token:', error.message);
2111
+ apiError(res, 500, 'internal_error', error.message || 'Failed to register push token');
2112
+ }
2113
+ });
2114
+
2115
+ /**
2116
+ * DELETE /api/developer/v1/push/expo-token
2117
+ * Unregister an Expo push token for the authenticated user.
2118
+ * Requires: API key + user JWT (dual auth)
2119
+ */
2120
+ apiRouter.delete('/push/expo-token', developerUserAuth, async (req, res) => {
2121
+ try {
2122
+ const { token } = req.body;
2123
+ if (!token) {
2124
+ return apiError(res, 400, 'missing_params', 'token is required');
2125
+ }
2126
+
2127
+ await expoPushService.unregisterToken(req.developerUser.userId, token);
2128
+
2129
+ res.json({
2130
+ success: true,
2131
+ data: { message: 'Token unregistered' },
2132
+ });
2133
+ } catch (error) {
2134
+ console.error('[DevAPI] Error unregistering push token:', error.message);
2135
+ apiError(res, 500, 'internal_error', 'Failed to unregister push token');
2136
+ }
2137
+ });
2138
+
2139
+ /**
2140
+ * POST /api/developer/v1/push/simulate
2141
+ * Send a test push notification to a specific user.
2142
+ * Requires: API key + user JWT (dual auth)
2143
+ */
2144
+ apiRouter.post('/push/simulate', developerUserAuth, async (req, res) => {
2145
+ try {
2146
+ const { userId, title, body, data } = req.body;
2147
+ if (!userId) {
2148
+ return apiError(res, 400, 'missing_params', 'userId is required');
2149
+ }
2150
+
2151
+ const result = await expoPushService.sendToUser(userId, {
2152
+ title: title || 'Test Notification',
2153
+ body: body || 'This is a test push from the Dubs developer dashboard',
2154
+ data: data || { type: 'test' },
2155
+ });
2156
+
2157
+ res.json({
2158
+ success: true,
2159
+ data: result,
2160
+ });
2161
+ } catch (error) {
2162
+ console.error('[DevAPI] Error simulating push:', error.message);
2163
+ apiError(res, 500, 'internal_error', 'Failed to send push notification');
2164
+ }
2165
+ });
2166
+
2167
+ /**
2168
+ * GET /api/developer/v1/auth/check-username/:username
2169
+ * Check if a username is available.
2170
+ * Requires: API key only
2171
+ */
2172
+ apiRouter.get('/auth/check-username/:username', async (req, res) => {
2173
+ try {
2174
+ const { username } = req.params;
2175
+
2176
+ // Validate format
2177
+ if (!username || username.length < 3) {
2178
+ return res.json({ success: true, data: { available: false, reason: 'Username must be at least 3 characters' } });
2179
+ }
2180
+ if (username.length > 20) {
2181
+ return res.json({ success: true, data: { available: false, reason: 'Username must be 20 characters or less' } });
2182
+ }
2183
+ if (!/^[a-zA-Z0-9_]+$/.test(username)) {
2184
+ return res.json({ success: true, data: { available: false, reason: 'Username can only contain letters, numbers, and underscores' } });
2185
+ }
2186
+
2187
+ const result = await pool.query(
2188
+ 'SELECT id FROM users WHERE LOWER(username) = LOWER($1)',
2189
+ [username]
2190
+ );
2191
+
2192
+ res.json({
2193
+ success: true,
2194
+ data: { available: result.rows.length === 0 },
2195
+ });
2196
+ } catch (error) {
2197
+ console.error('[DevAPI] Error checking username:', error.message);
2198
+ apiError(res, 500, 'internal_error', 'Failed to check username');
2199
+ }
2200
+ });
2201
+
2202
+ /**
2203
+ * GET /api/developer/v1/users/:walletAddress
2204
+ * Public profile lookup (no JWT needed).
2205
+ * Returns null if wallet not found (no 404).
2206
+ * Requires: API key only
2207
+ */
2208
+ apiRouter.get('/users/:walletAddress', async (req, res) => {
2209
+ try {
2210
+ const { walletAddress } = req.params;
2211
+
2212
+ const result = await pool.query(
2213
+ 'SELECT wallet_address, username, avatar, created_at FROM users WHERE wallet_address = $1',
2214
+ [walletAddress]
2215
+ );
2216
+
2217
+ if (result.rows.length === 0) {
2218
+ return res.json({ success: true, data: { user: null } });
2219
+ }
2220
+
2221
+ const user = result.rows[0];
2222
+ res.json({
2223
+ success: true,
2224
+ data: {
2225
+ user: {
2226
+ walletAddress: user.wallet_address,
2227
+ username: user.username,
2228
+ avatar: user.avatar,
2229
+ createdAt: user.created_at,
2230
+ },
2231
+ },
2232
+ });
2233
+ } catch (error) {
2234
+ console.error('[DevAPI] Error fetching user:', error.message);
2235
+ apiError(res, 500, 'internal_error', 'Failed to fetch user');
2236
+ }
2237
+ });
2238
+
2239
+ /**
2240
+ * GET /api/developer/v1/users
2241
+ * List users who have authenticated through this developer's app.
2242
+ * Paginated with limit/offset.
2243
+ * Requires: API key only
2244
+ */
2245
+ apiRouter.get('/users', async (req, res) => {
2246
+ try {
2247
+ const { appId } = req.developerApp;
2248
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 20));
2249
+ const offset = Math.max(0, parseInt(req.query.offset) || 0);
2250
+
2251
+ const countResult = await pool.query(
2252
+ 'SELECT COUNT(*) FROM developer_app_users WHERE developer_app_id = $1',
2253
+ [appId]
2254
+ );
2255
+ const total = parseInt(countResult.rows[0].count);
2256
+
2257
+ const result = await pool.query(`
2258
+ SELECT
2259
+ u.wallet_address,
2260
+ u.username,
2261
+ u.avatar,
2262
+ u.created_at,
2263
+ dau.first_seen_at,
2264
+ dau.last_seen_at
2265
+ FROM developer_app_users dau
2266
+ JOIN users u ON dau.user_id = u.id
2267
+ WHERE dau.developer_app_id = $1
2268
+ ORDER BY dau.last_seen_at DESC
2269
+ LIMIT $2 OFFSET $3
2270
+ `, [appId, limit, offset]);
2271
+
2272
+ res.json({
2273
+ success: true,
2274
+ data: {
2275
+ users: result.rows.map(u => ({
2276
+ walletAddress: u.wallet_address,
2277
+ username: u.username,
2278
+ avatar: u.avatar,
2279
+ createdAt: u.created_at,
2280
+ firstSeenAt: u.first_seen_at,
2281
+ lastSeenAt: u.last_seen_at,
2282
+ })),
2283
+ pagination: { total, limit, offset },
2284
+ },
2285
+ });
2286
+ } catch (error) {
2287
+ console.error('[DevAPI] Error listing app users:', error.message);
2288
+ apiError(res, 500, 'internal_error', 'Failed to list users');
2289
+ }
2290
+ });
2291
+
2292
+ /**
2293
+ * Parse a duration string like '7d', '24h', '30m' into milliseconds.
2294
+ */
2295
+ function parseDuration(str) {
2296
+ const match = str.match(/^(\d+)([dhms])$/);
2297
+ if (!match) return 7 * 24 * 60 * 60 * 1000; // default 7 days
2298
+ const val = parseInt(match[1]);
2299
+ switch (match[2]) {
2300
+ case 'd': return val * 24 * 60 * 60 * 1000;
2301
+ case 'h': return val * 60 * 60 * 1000;
2302
+ case 'm': return val * 60 * 1000;
2303
+ case 's': return val * 1000;
2304
+ default: return 7 * 24 * 60 * 60 * 1000;
2305
+ }
2306
+ }
2307
+
2308
+ // ── Unified Events ──
2309
+
2310
+ /**
2311
+ * GET /api/developer/v1/events/upcoming
2312
+ * Unified paginated endpoint for ALL upcoming bettable events (sports + esports).
2313
+ *
2314
+ * Query params:
2315
+ * type - "sports" or "esports" (optional, returns both if omitted)
2316
+ * game - NBA, UFC, cs-go, valorant, etc. (optional)
2317
+ * page - Page number (default: 1)
2318
+ * per_page - Items per page (default: 20, max: 100)
2319
+ */
2320
+ apiRouter.get('/events/upcoming', async (req, res) => {
2321
+ try {
2322
+ const typeFilter = req.query.type?.toLowerCase();
2323
+ const gameFilter = req.query.game?.toLowerCase();
2324
+ const page = Math.max(1, parseInt(req.query.page) || 1);
2325
+ const perPage = Math.min(100, Math.max(1, parseInt(req.query.per_page) || 20));
2326
+
2327
+ if (typeFilter && !['sports', 'esports'].includes(typeFilter)) {
2328
+ return apiError(res, 400, 'invalid_request', 'type must be "sports" or "esports"');
2329
+ }
2330
+
2331
+ if (gameFilter && !GAME_PARAM_MAP[gameFilter]) {
2332
+ return apiError(res, 400, 'invalid_request', `Unknown game "${req.query.game}". Valid: ${Object.keys(GAME_PARAM_MAP).join(', ')}`);
2333
+ }
2334
+
2335
+ // Determine which sources to fetch
2336
+ let sportsLeagues = [];
2337
+ let esportsVideogames = [];
2338
+
2339
+ if (gameFilter) {
2340
+ const mapping = GAME_PARAM_MAP[gameFilter];
2341
+ if (mapping.type === 'sports') sportsLeagues = [mapping.league];
2342
+ else esportsVideogames = [mapping.videogame];
2343
+ } else {
2344
+ if (!typeFilter || typeFilter === 'sports') sportsLeagues = ALL_SPORTS_LEAGUES;
2345
+ if (!typeFilter || typeFilter === 'esports') esportsVideogames = ALL_ESPORTS_VIDEOGAMES;
2346
+ }
2347
+
2348
+ // Respect type filter even when game is set
2349
+ if (typeFilter === 'sports') esportsVideogames = [];
2350
+ if (typeFilter === 'esports') sportsLeagues = [];
2351
+
2352
+ // Fetch all sources in parallel
2353
+ const fetches = [];
2354
+ for (const league of sportsLeagues) {
2355
+ fetches.push(fetchSportsEvents(league).then(events => events.map(e => normalizeToUnifiedSportsEvent(e, league))));
2356
+ }
2357
+ for (const vg of esportsVideogames) {
2358
+ fetches.push(fetchEsportsMatches(vg).then(matches => matches.map(m => normalizeToUnifiedEsportsEvent(m))));
2359
+ }
2360
+
2361
+ const results = await Promise.all(fetches);
2362
+ let allEvents = results.flat();
2363
+
2364
+ // Only bettable events — exclude finished, canceled, and past-start-time
2365
+ const now = new Date();
2366
+ allEvents = allEvents.filter(e => {
2367
+ if (e.status !== 'upcoming' && e.status !== 'live') return false;
2368
+ // Exclude events whose start time has already passed (upstream API can be slow to update status)
2369
+ if (e.startTime && new Date(e.startTime) < now) return false;
2370
+ return true;
2371
+ });
2372
+
2373
+ // Sort by startTime ascending
2374
+ allEvents.sort((a, b) => {
2375
+ const da = a.startTime ? new Date(a.startTime).getTime() : Infinity;
2376
+ const db = b.startTime ? new Date(b.startTime).getTime() : Infinity;
2377
+ return da - db;
2378
+ });
2379
+
2380
+ // Paginate
2381
+ const total = allEvents.length;
2382
+ const totalPages = Math.ceil(total / perPage);
2383
+ const events = allEvents.slice((page - 1) * perPage, page * perPage);
2384
+
2385
+ res.json({ success: true, events, pagination: { page, perPage, total, totalPages } });
2386
+ } catch (error) {
2387
+ console.error('[DevAPI] Error in unified events endpoint:', error.message);
2388
+ apiError(res, 500, 'internal_error', 'Failed to fetch events');
2389
+ }
2390
+ });
2391
+
2392
+ // ── Type-Specific Data (also normalized) ──
2393
+
2394
+ /**
2395
+ * GET /api/developer/v1/sports/events/:league
2396
+ * Upcoming sports events for a single league (normalized shape)
2397
+ */
2398
+ apiRouter.get('/sports/events/:league', async (req, res) => {
2399
+ try {
2400
+ const { league } = req.params;
2401
+ const rawEvents = await fetchSportsEvents(league);
2402
+ const events = rawEvents.map(e => normalizeToUnifiedSportsEvent(e, league.toUpperCase()));
2403
+ res.json({ success: true, league, events });
2404
+ } catch (error) {
2405
+ console.error('[DevAPI] Error fetching sports events:', error.message);
2406
+ apiError(res, 500, 'internal_error', 'Failed to fetch sports events');
2407
+ }
2408
+ });
2409
+
2410
+ /**
2411
+ * GET /api/developer/v1/esports/matches/upcoming
2412
+ * Upcoming esports matches (normalized shape)
2413
+ */
2414
+ apiRouter.get('/esports/matches/upcoming', async (req, res) => {
2415
+ try {
2416
+ const rawMatches = await fetchEsportsMatches(req.query.videogame || null);
2417
+ const matches = rawMatches.map(m => normalizeToUnifiedEsportsEvent(m));
2418
+ res.json({ success: true, matches });
2419
+ } catch (error) {
2420
+ console.error('[DevAPI] Error fetching esports matches:', error.message);
2421
+ apiError(res, 500, 'internal_error', 'Failed to fetch esports matches');
2422
+ }
2423
+ });
2424
+
2425
+ /**
2426
+ * GET /api/developer/v1/esports/matches/:matchId
2427
+ * Single esports match detail (detailed shape, not unified)
2428
+ */
2429
+ apiRouter.get('/esports/matches/:matchId', async (req, res) => {
2430
+ try {
2431
+ const response = await axios.get(`${BASE_URL_INTERNAL}/api/esports/matches/${req.params.matchId}`, { timeout: 15000 });
2432
+ const rawMatch = response.data?.data || response.data;
2433
+ const match = transformEsportsMatchDetail(rawMatch);
2434
+ res.json({ success: true, match });
2435
+ } catch (error) {
2436
+ console.error('[DevAPI] Error fetching esports match:', error.message);
2437
+ const status = error.response?.status || 500;
2438
+ apiError(res, status, status === 404 ? 'match_not_found' : 'internal_error', 'Failed to fetch esports match');
2439
+ }
2440
+ });
2441
+
2442
+ // ── Shared: Event ID Resolution ──
2443
+
2444
+ /**
2445
+ * Server-side stash for event data between create → confirm.
2446
+ * Keyed by gameId, auto-expires after 10 minutes.
2447
+ * This keeps raw provider data (sportsEvent, gameMode) off the public API.
2448
+ */
2449
+ const pendingGameEvents = new Map();
2450
+ const STASH_TTL_MS = 10 * 60 * 1000;
2451
+
2452
+ function stashEventData(gameId, data) {
2453
+ pendingGameEvents.set(gameId, { ...data, _createdAt: Date.now() });
2454
+ // Lazy cleanup: purge expired entries
2455
+ for (const [key, val] of pendingGameEvents) {
2456
+ if (Date.now() - val._createdAt > STASH_TTL_MS) pendingGameEvents.delete(key);
2457
+ }
2458
+ }
2459
+
2460
+ function popEventData(gameId) {
2461
+ const data = pendingGameEvents.get(gameId);
2462
+ pendingGameEvents.delete(gameId);
2463
+ return data || null;
2464
+ }
2465
+
2466
+ /**
2467
+ * Error helper — consistent error shape across all endpoints.
2468
+ * @param {object} res - Express response
2469
+ * @param {number} httpStatus - HTTP status code
2470
+ * @param {string} code - Machine-readable error code
2471
+ * @param {string} message - Human-readable message
2472
+ */
2473
+ function apiError(res, httpStatus, code, message) {
2474
+ return res.status(httpStatus).json({ success: false, error: { code, message } });
2475
+ }
2476
+
2477
+ // ============================================================
2478
+ // SOLANA PROGRAM ERROR MAP
2479
+ // ============================================================
2480
+ // Custom error codes from dubs_solana_program (starts at 6000)
2481
+ const SOLANA_PROGRAM_ERRORS = {
2482
+ 6000: { code: 'invalid_amount', message: 'Amount must be greater than 0' },
2483
+ 6001: { code: 'invalid_max_players', message: 'Max players must be between 1 and 20' },
2484
+ 6002: { code: 'game_not_active', message: 'Game is not active' },
2485
+ 6003: { code: 'game_full', message: 'Game is full' },
2486
+ 6004: { code: 'player_already_joined', message: 'Player has already joined this game' },
2487
+ 6005: { code: 'insufficient_funds', message: 'Insufficient funds in pot' },
2488
+ 6006: { code: 'unauthorized', message: 'Only the game creator can perform this action' },
2489
+ 6007: { code: 'invalid_winner_index', message: 'Invalid winner index' },
2490
+ 6008: { code: 'game_still_active', message: 'Game is still active — must close before resetting' },
2491
+ 6009: { code: 'pot_not_empty', message: 'Pot must be empty before resetting — distribute winnings first' },
2492
+ 6010: { code: 'no_winners_specified', message: 'At least one winner must be specified' },
2493
+ 6011: { code: 'mismatched_winners_percentages', message: 'Number of winners must match number of percentages' },
2494
+ 6012: { code: 'percentages_invalid', message: 'Percentages must sum to exactly 100' },
2495
+ 6013: { code: 'winner_account_mismatch', message: 'Winner account does not match expected player' },
2496
+ 6014: { code: 'invalid_operator_fee', message: 'Operator fee must be between 0 and 100' },
2497
+ 6015: { code: 'operator_wallet_mismatch', message: 'Operator wallet does not match game settings' },
2498
+ 6016: { code: 'winner_not_in_game', message: 'Winner address is not a player in this game' },
2499
+ 6017: { code: 'voting_not_enabled', message: 'Voting is not enabled for this game' },
2500
+ 6018: { code: 'voter_not_in_game', message: 'Voter is not a player in this game' },
2501
+ 6019: { code: 'voted_for_not_in_game', message: 'Cannot vote for someone who is not in the game' },
2502
+ 6020: { code: 'voting_incomplete', message: 'Voting incomplete — need majority of players to vote' },
2503
+ 6021: { code: 'invalid_referee_commission', message: 'Referee commission must be between 0 and 100' },
2504
+ 6022: { code: 'total_fees_exceed_100', message: 'Total fees (operator + referee) cannot exceed 100%' },
2505
+ 6023: { code: 'referee_mode_requires_referee', message: 'Referee mode requires a referee address' },
2506
+ 6024: { code: 'referee_must_earn_commission', message: 'Referee must earn commission' },
2507
+ 6025: { code: 'referee_cannot_be_player', message: 'Referee cannot join as a player (conflict of interest)' },
2508
+ 6026: { code: 'only_referee_can_vote', message: 'Only the referee can vote in Referee mode' },
2509
+ 6027: { code: 'referee_account_mismatch', message: 'Referee account does not match game settings' },
2510
+ 6028: { code: 'invalid_lock_time', message: 'Lock time must be in the future' },
2511
+ 6029: { code: 'game_locked', message: 'Game is locked — no more players can join' },
2512
+ 6030: { code: 'lock_time_passed', message: 'Lock time has passed — game is now locked' },
2513
+ 6031: { code: 'unauthorized_oracle', message: 'Only authorized oracle can resolve this game' },
2514
+ 6032: { code: 'game_not_locked', message: 'Game must be locked before it can be resolved' },
2515
+ 6033: { code: 'already_resolved', message: 'Game has already been resolved' },
2516
+ 6034: { code: 'game_not_resolved', message: 'Game has not been resolved yet' },
2517
+ 6035: { code: 'player_not_in_game', message: 'Player is not in this game' },
2518
+ 6036: { code: 'not_a_winner', message: 'Player did not win this game' },
2519
+ 6037: { code: 'invalid_game_mode', message: 'Invalid game mode for this operation' },
2520
+ 6038: { code: 'already_claimed', message: 'Player has already claimed their winnings' },
2521
+ 6039: { code: 'lock_time_too_soon', message: 'Lock time must be at least 2 minutes in the future' },
2522
+ 6040: { code: 'operator_account_missing', message: 'Operator account must be provided' },
2523
+ 6041: { code: 'no_winners_to_distribute', message: 'No winners to distribute funds to' },
2524
+ 6042: { code: 'insufficient_funds_for_rent', message: 'Insufficient funds to maintain rent exemption' },
2525
+ 6043: { code: 'cannot_resolve_before_lock', message: 'Cannot resolve game before lock time has passed' },
2526
+ 6044: { code: 'emergency_refund_not_available', message: 'Emergency refund not available yet — must wait 7 days after lock time' },
2527
+ 6045: { code: 'sponsor_account_missing', message: 'Sponsor account must be provided for tie refund' },
2528
+ 6046: { code: 'sponsor_wallet_mismatch', message: 'Sponsor wallet does not match stored sponsor' },
2529
+ 6047: { code: 'invalid_bet_amount', message: 'Bet amount must be greater than zero' },
2530
+ 6048: { code: 'no_survivors_to_distribute', message: 'No survivors to distribute winnings to' },
2531
+ 6049: { code: 'too_many_survivors', message: 'Too many survivors (max 50 per batch)' },
2532
+ };
2533
+
2534
+ // Known Solana built-in instruction errors
2535
+ const SOLANA_BUILTIN_ERRORS = {
2536
+ 0: { code: 'generic_error', message: 'Generic instruction error' },
2537
+ 1: { code: 'invalid_argument', message: 'Invalid argument passed to program' },
2538
+ 2: { code: 'invalid_instruction_data', message: 'Invalid instruction data' },
2539
+ 3: { code: 'invalid_account_data', message: 'Invalid account data' },
2540
+ 4: { code: 'account_data_too_small', message: 'Account data too small' },
2541
+ 5: { code: 'insufficient_funds', message: 'Insufficient funds for transaction' },
2542
+ 6: { code: 'incorrect_program_id', message: 'Incorrect program ID' },
2543
+ 7: { code: 'missing_required_signature', message: 'Missing required signature' },
2544
+ 8: { code: 'account_already_initialized', message: 'Account already initialized' },
2545
+ 9: { code: 'uninitialized_account', message: 'Attempt to operate on uninitialized account' },
2546
+ };
2547
+
2548
+ /**
2549
+ * Parse a raw Solana transaction error into a normalized { code, message } object.
2550
+ * Handles: { InstructionError: [idx, { Custom: N }] }
2551
+ * { InstructionError: [idx, "SomeBuiltinError"] }
2552
+ * String errors, etc.
2553
+ */
2554
+ function parseSolanaError(err) {
2555
+ if (!err) return { code: 'unknown_error', message: 'Unknown transaction error' };
2556
+
2557
+ // Handle string input (from pollTransactionConfirmation)
2558
+ if (typeof err === 'string') {
2559
+ try {
2560
+ // Try to extract JSON from "Transaction failed: {...}"
2561
+ const jsonMatch = err.match(/\{.*\}/s);
2562
+ if (jsonMatch) err = JSON.parse(jsonMatch[0]);
2563
+ else return { code: 'transaction_failed', message: err };
2564
+ } catch {
2565
+ return { code: 'transaction_failed', message: err };
2566
+ }
2567
+ }
2568
+
2569
+ // { InstructionError: [index, details] }
2570
+ if (err.InstructionError) {
2571
+ const [ixIndex, details] = err.InstructionError;
2572
+
2573
+ // { Custom: 6004 }
2574
+ if (details && typeof details === 'object' && details.Custom != null) {
2575
+ const customCode = details.Custom;
2576
+ const known = SOLANA_PROGRAM_ERRORS[customCode];
2577
+ if (known) return known;
2578
+ return { code: `program_error_${customCode}`, message: `Program error code ${customCode} (instruction ${ixIndex})` };
2579
+ }
2580
+
2581
+ // Built-in enum like "InsufficientFundsForRent" or numeric
2582
+ if (typeof details === 'string') {
2583
+ const snake = details.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
2584
+ return { code: snake, message: details.replace(/([a-z])([A-Z])/g, '$1 $2') };
2585
+ }
2586
+ if (typeof details === 'number') {
2587
+ const known = SOLANA_BUILTIN_ERRORS[details];
2588
+ if (known) return known;
2589
+ return { code: `instruction_error_${details}`, message: `Instruction error ${details}` };
2590
+ }
2591
+
2592
+ return { code: 'instruction_error', message: `Instruction ${ixIndex} failed: ${JSON.stringify(details)}` };
2593
+ }
2594
+
2595
+ return { code: 'transaction_failed', message: typeof err === 'object' ? JSON.stringify(err) : String(err) };
2596
+ }
2597
+
2598
+ /**
2599
+ * Simulate a base64-encoded transaction against the RPC.
2600
+ * If simulation reveals an on-chain error, sends apiError and returns false.
2601
+ * If simulation passes (or RPC is unreachable), returns true to continue.
2602
+ */
2603
+ async function simulateTransactionOrFail(base64Tx, res, label) {
2604
+ try {
2605
+ const tx = Transaction.from(Buffer.from(base64Tx, 'base64'));
2606
+ const simResult = await connection.simulateTransaction(tx);
2607
+ if (simResult.value.err) {
2608
+ console.error(`[DevAPI] ${label} simulation failed:`, JSON.stringify(simResult.value.err));
2609
+ const parsed = parseSolanaError(simResult.value.err);
2610
+ apiError(res, 400, parsed.code, parsed.message);
2611
+ return false;
2612
+ }
2613
+ return true;
2614
+ } catch (simError) {
2615
+ console.warn(`[DevAPI] ${label} simulation RPC error (non-blocking):`, simError.message);
2616
+ return true;
2617
+ }
2618
+ }
2619
+
2620
+ // ============================================================
2621
+ // WEBHOOK DELIVERY
2622
+ // ============================================================
2623
+
2624
+ const ALLOWED_WEBHOOK_EVENTS = ['game.created', 'game.joined', 'game.resolved'];
2625
+
2626
+ /**
2627
+ * Fire webhooks for a developer app event.
2628
+ * Non-blocking — errors are logged, never thrown to the caller.
2629
+ * Signs payload with HMAC-SHA256 using the webhook's secret.
2630
+ */
2631
+ async function fireWebhooks(appId, event, payload) {
2632
+ try {
2633
+ const { rows: hooks } = await pool.query(
2634
+ `SELECT id, url, secret FROM developer_webhooks
2635
+ WHERE app_id = $1 AND is_active = TRUE AND $2 = ANY(events)`,
2636
+ [appId, event]
2637
+ );
2638
+ if (hooks.length === 0) return;
2639
+
2640
+ const body = JSON.stringify({ event, timestamp: new Date().toISOString(), data: payload });
2641
+
2642
+ for (const hook of hooks) {
2643
+ const sig = crypto.createHmac('sha256', hook.secret).update(body).digest('hex');
2644
+ deliverWebhook(hook, event, body, sig, 1);
2645
+ }
2646
+
2647
+ console.log(`[Webhooks] Fired ${event} to ${hooks.length} hook(s) for app ${appId}`);
2648
+ } catch (err) {
2649
+ console.error(`[Webhooks] Failed to query webhooks for app ${appId}:`, err.message);
2650
+ }
2651
+ }
2652
+
2653
+ async function deliverWebhook(hook, event, body, signature, attempt) {
2654
+ const MAX_ATTEMPTS = 3;
2655
+ try {
2656
+ const res = await axios.post(hook.url, body, {
2657
+ headers: {
2658
+ 'Content-Type': 'application/json',
2659
+ 'X-Dubs-Signature': signature,
2660
+ 'X-Dubs-Event': event,
2661
+ },
2662
+ timeout: 10000,
2663
+ });
2664
+
2665
+ await pool.query(
2666
+ `INSERT INTO developer_webhook_logs (webhook_id, event, payload, status_code, response_body, attempts, success)
2667
+ VALUES ($1, $2, $3, $4, $5, $6, TRUE)`,
2668
+ [hook.id, event, body, res.status, String(res.data).slice(0, 500), attempt]
2669
+ ).catch(() => {});
2670
+ } catch (err) {
2671
+ const statusCode = err.response?.status || null;
2672
+ const errorMsg = err.message;
2673
+
2674
+ if (attempt < MAX_ATTEMPTS) {
2675
+ setTimeout(() => deliverWebhook(hook, event, body, signature, attempt + 1), attempt * 2000);
2676
+ return;
2677
+ }
2678
+
2679
+ await pool.query(
2680
+ `INSERT INTO developer_webhook_logs (webhook_id, event, payload, status_code, attempts, success, error)
2681
+ VALUES ($1, $2, $3, $4, $5, FALSE, $6)`,
2682
+ [hook.id, event, body, statusCode, attempt, errorMsg]
2683
+ ).catch(() => {});
2684
+ console.warn(`[Webhooks] Delivery failed for hook ${hook.id} after ${attempt} attempts: ${errorMsg}`);
2685
+ }
2686
+ }
2687
+
2688
+ // ============================================================
2689
+ // EVENT RESOLUTION
2690
+ // ============================================================
2691
+
2692
+ /**
2693
+ * Parse a namespaced event ID and resolve it to internal data.
2694
+ * Sports ID format: "sports:LEAGUE:EVENT_ID" e.g. "sports:UFC:espn-ufc-600057329"
2695
+ * Esports ID format: "esports:MATCH_ID" e.g. "esports:1353988"
2696
+ *
2697
+ * Returns: { type, gameMode, sportsEvent, lockTimestamp, sportsEventId } or { error }
2698
+ */
2699
+ async function resolveEventId(id) {
2700
+ if (!id || typeof id !== 'string') {
2701
+ return { error: { status: 400, code: 'invalid_request', message: 'id is required' } };
2702
+ }
2703
+
2704
+ const colonIdx = id.indexOf(':');
2705
+ if (colonIdx === -1) {
2706
+ return { error: { status: 400, code: 'invalid_id_format', message: `Invalid id "${id}". Use "esports:MATCH_ID" or "sports:LEAGUE:EVENT_ID"` } };
2707
+ }
2708
+
2709
+ const prefix = id.slice(0, colonIdx);
2710
+ const rest = id.slice(colonIdx + 1);
2711
+
2712
+ // ── Esports ──
2713
+ if (prefix === 'esports') {
2714
+ try {
2715
+ const response = await axios.post(`${BASE_URL_INTERNAL}/api/esports/games/validate`, {
2716
+ pandascoreMatchId: rest,
2717
+ }, { timeout: 15000 });
2718
+
2719
+ const data = response.data;
2720
+ if (!data.success) {
2721
+ return { error: { status: 400, code: 'event_not_bettable', message: data.error || 'Event is not bettable' } };
2722
+ }
2723
+
2724
+ return {
2725
+ type: 'esports',
2726
+ gameMode: 5,
2727
+ sportsEvent: data.sportsEvent,
2728
+ lockTimestamp: data.match?.lockTimestamp,
2729
+ sportsEventId: rest,
2730
+ startTime: normalizeTimestamp(data.match?.scheduledAt),
2731
+ status: normalizeEsportsStatus(data.match?.status),
2732
+ };
2733
+ } catch (err) {
2734
+ const msg = err.response?.data?.error || 'Failed to validate esports event';
2735
+ const status = err.response?.status || 500;
2736
+ return { error: { status, code: status === 404 ? 'event_not_found' : 'event_not_bettable', message: msg } };
2737
+ }
2738
+ }
2739
+
2740
+ // ── Sports ──
2741
+ if (prefix === 'sports') {
2742
+ const secondColon = rest.indexOf(':');
2743
+ if (secondColon === -1) {
2744
+ return { error: { status: 400, code: 'invalid_id_format', message: 'Sports id must be "sports:LEAGUE:EVENT_ID"' } };
2745
+ }
2746
+
2747
+ const league = rest.slice(0, secondColon);
2748
+ const eventId = rest.slice(secondColon + 1);
2749
+
2750
+ if (!ALL_SPORTS_LEAGUES.includes(league)) {
2751
+ return { error: { status: 400, code: 'unknown_league', message: `Unknown league "${league}". Valid: ${ALL_SPORTS_LEAGUES.join(', ')}` } };
2752
+ }
2753
+
2754
+ const events = await fetchSportsEvents(league);
2755
+ const event = events.find(e => e.idEvent === eventId);
2756
+
2757
+ if (!event) {
2758
+ return { error: { status: 404, code: 'event_not_found', message: `Event "${eventId}" not found in ${league}` } };
2759
+ }
2760
+
2761
+ const status = normalizeSportsStatus(event.strStatus);
2762
+ if (status === 'finished') {
2763
+ return { error: { status: 400, code: 'event_not_bettable', message: 'Event has already finished' } };
2764
+ }
2765
+ if (status === 'canceled') {
2766
+ return { error: { status: 400, code: 'event_not_bettable', message: 'Event is canceled or postponed' } };
2767
+ }
2768
+
2769
+ const startTime = new Date(event.strTimestamp + (event.strTimestamp.endsWith('Z') ? '' : 'Z'));
2770
+ const isLive = status === 'live';
2771
+ if (!isLive) {
2772
+ const minTime = new Date(Date.now() + 2 * 60 * 1000);
2773
+ if (startTime <= minTime) {
2774
+ return { error: { status: 400, code: 'event_not_bettable', message: 'Event starts too soon — must be at least 2 minutes from now' } };
2775
+ }
2776
+ }
2777
+
2778
+ const lockTimestamp = isLive
2779
+ ? Math.floor((Date.now() + 5 * 60 * 1000) / 1000)
2780
+ : Math.floor(startTime.getTime() / 1000);
2781
+
2782
+ return {
2783
+ type: 'sports',
2784
+ gameMode: 4,
2785
+ sportsEvent: event,
2786
+ lockTimestamp,
2787
+ sportsEventId: eventId,
2788
+ league,
2789
+ startTime: normalizeTimestamp(event.strTimestamp),
2790
+ status,
2791
+ };
2792
+ }
2793
+
2794
+ return { error: { status: 400, code: 'unknown_event_type', message: `Unknown type "${prefix}". Supported: esports, sports` } };
2795
+ }
2796
+
2797
+ // ── Game Lifecycle ──
2798
+
2799
+ /**
2800
+ * POST /api/developer/v1/games/validate
2801
+ * Check if an event is bettable. Lightweight yes/no check.
2802
+ * Body: { id: "sports:UFC:espn-ufc-600057329" } or { id: "esports:1353988" }
2803
+ */
2804
+ apiRouter.post('/games/validate', async (req, res) => {
2805
+ try {
2806
+ const resolved = await resolveEventId(req.body.id);
2807
+
2808
+ if (resolved.error) {
2809
+ return apiError(res, resolved.error.status, resolved.error.code, resolved.error.message);
2810
+ }
2811
+
2812
+ res.json({
2813
+ success: true,
2814
+ bettable: true,
2815
+ gameMode: resolved.gameMode,
2816
+ lockTimestamp: resolved.lockTimestamp,
2817
+ startTime: resolved.startTime,
2818
+ status: resolved.status,
2819
+ });
2820
+ } catch (error) {
2821
+ console.error('[DevAPI] Error validating game:', error.message);
2822
+ apiError(res, 500, 'internal_error', 'Failed to validate event');
2823
+ }
2824
+ });
2825
+
2826
+ /**
2827
+ * POST /api/developer/v1/games/create
2828
+ * Resolve event, validate, and build an unsigned create+join transaction.
2829
+ * Body: { id, playerWallet, teamChoice, wagerAmount }
2830
+ * Developer never handles raw event data — we fetch it from the event ID.
2831
+ */
2832
+ apiRouter.post('/games/create', async (req, res) => {
2833
+ try {
2834
+ const { id, playerWallet, teamChoice, wagerAmount } = req.body;
2835
+
2836
+ if (!id) return apiError(res, 400, 'invalid_request', 'id is required');
2837
+ if (!playerWallet) return apiError(res, 400, 'invalid_request', 'playerWallet is required');
2838
+ if (!teamChoice) return apiError(res, 400, 'invalid_request', 'teamChoice is required');
2839
+ if (!wagerAmount) return apiError(res, 400, 'invalid_request', 'wagerAmount is required');
2840
+ if (!['home', 'away', 'draw'].includes(teamChoice)) {
2841
+ return apiError(res, 400, 'invalid_team_choice', 'teamChoice must be home, away, or draw');
2842
+ }
2843
+
2844
+ // Resolve event ID → sportsEvent + lockTimestamp + gameMode
2845
+ const resolved = await resolveEventId(id);
2846
+ if (resolved.error) {
2847
+ return apiError(res, resolved.error.status, resolved.error.code, resolved.error.message);
2848
+ }
2849
+
2850
+ // Generate game ID in the same format as the existing frontend
2851
+ const gameId = `sport-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
2852
+
2853
+ // Build unsigned transaction
2854
+ const txResponse = await axios.post(
2855
+ `${BASE_URL_INTERNAL}/api/v1/prod/transaction/build/create-and-join-automatic`,
2856
+ {
2857
+ creatorAddress: playerWallet,
2858
+ buyIn: wagerAmount,
2859
+ lockTimestamp: resolved.lockTimestamp,
2860
+ sportsEventId: resolved.sportsEventId,
2861
+ teamChoice,
2862
+ gameId,
2863
+ },
2864
+ { timeout: 15000 }
2865
+ );
2866
+
2867
+ if (!txResponse.data.success) {
2868
+ return apiError(res, 400, 'transaction_failed', 'Failed to build transaction');
2869
+ }
2870
+
2871
+ // Build normalized event for the developer to display
2872
+ let event;
2873
+ if (resolved.type === 'sports') {
2874
+ event = normalizeToUnifiedSportsEvent(resolved.sportsEvent, resolved.league);
2875
+ } else {
2876
+ const se = resolved.sportsEvent || {};
2877
+ event = {
2878
+ id: `esports:${resolved.sportsEventId}`,
2879
+ type: 'esports',
2880
+ title: se.strEvent || null,
2881
+ league: se.strLeague || null,
2882
+ game: se.strSport || null,
2883
+ startTime: resolved.startTime,
2884
+ status: resolved.status,
2885
+ opponents: [
2886
+ { name: se.strHomeTeam || null, imageUrl: null, score: null },
2887
+ { name: se.strAwayTeam || null, imageUrl: null, score: null },
2888
+ ],
2889
+ };
2890
+ }
2891
+
2892
+ const ok = await simulateTransactionOrFail(txResponse.data.transaction, res, 'CreateGame');
2893
+ if (!ok) return;
2894
+
2895
+ // Stash raw event data server-side for confirm (never sent to client)
2896
+ stashEventData(txResponse.data.gameId, {
2897
+ sportsEvent: resolved.sportsEvent,
2898
+ gameMode: resolved.gameMode,
2899
+ lockTimestamp: resolved.lockTimestamp,
2900
+ });
2901
+
2902
+ res.json({
2903
+ success: true,
2904
+ gameId: txResponse.data.gameId,
2905
+ gameAddress: txResponse.data.gameAddress,
2906
+ transaction: txResponse.data.transaction,
2907
+ lockTimestamp: resolved.lockTimestamp,
2908
+ event,
2909
+ });
2910
+ } catch (error) {
2911
+ console.error('[DevAPI] Error creating game:', error.message);
2912
+ apiError(res, 500, 'internal_error', 'Failed to create game');
2913
+ }
2914
+ });
2915
+
2916
+ /**
2917
+ * POST /api/developer/v1/games/join
2918
+ * Build an unsigned join transaction for an existing game.
2919
+ * Body: { playerWallet, gameId, teamChoice, amount }
2920
+ */
2921
+ apiRouter.post('/games/join', async (req, res) => {
2922
+ try {
2923
+ const { playerWallet, gameId, teamChoice, amount } = req.body;
2924
+
2925
+ if (!playerWallet) return apiError(res, 400, 'invalid_request', 'playerWallet is required');
2926
+ if (!gameId) return apiError(res, 400, 'invalid_request', 'gameId is required');
2927
+ if (!teamChoice) return apiError(res, 400, 'invalid_request', 'teamChoice is required');
2928
+ if (!amount) return apiError(res, 400, 'invalid_request', 'amount is required');
2929
+
2930
+ // ── Network mode visibility check ──
2931
+ const { appId, networkMode } = req.developerApp;
2932
+ const attrResult = await pool.query(
2933
+ `SELECT dga.app_id, da.network_mode
2934
+ FROM developer_game_attributions dga
2935
+ JOIN developer_apps da ON dga.app_id = da.id
2936
+ WHERE dga.game_id = $1`,
2937
+ [gameId]
2938
+ );
2939
+
2940
+ if (attrResult.rows.length > 0) {
2941
+ const gameApp = attrResult.rows[0];
2942
+ if (gameApp.network_mode === 'private' && gameApp.app_id !== appId) {
2943
+ return apiError(res, 403, 'game_not_accessible', 'This game is not available on your app');
2944
+ }
2945
+ if (networkMode === 'private' && gameApp.app_id !== appId) {
2946
+ return apiError(res, 403, 'game_not_accessible', 'Your app is in private mode — you can only join games created through your app');
2947
+ }
2948
+ } else if (networkMode === 'private') {
2949
+ // No attribution (main Dubs game) — private apps can't join these
2950
+ return apiError(res, 403, 'game_not_accessible', 'Your app is in private mode — you can only join games created through your app');
2951
+ }
2952
+
2953
+ const txResponse = await axios.post(
2954
+ `${BASE_URL_INTERNAL}/api/v1/prod/transaction/build/join-automatic`,
2955
+ { playerAddress: playerWallet, gameId, teamChoice, amount },
2956
+ { timeout: 15000 }
2957
+ );
2958
+
2959
+ const ok = await simulateTransactionOrFail(txResponse.data.transaction, res, 'JoinGame');
2960
+ if (!ok) return;
2961
+
2962
+ res.json({
2963
+ success: true,
2964
+ gameId,
2965
+ transaction: txResponse.data.transaction,
2966
+ gameAddress: txResponse.data.gameAddress,
2967
+ });
2968
+ } catch (error) {
2969
+ console.error('[DevAPI] Error building join tx:', error.message);
2970
+ apiError(res, 500, 'internal_error', 'Failed to build join transaction');
2971
+ }
2972
+ });
2973
+
2974
+ /**
2975
+ * POST /api/developer/v1/games/confirm
2976
+ * Confirm a signed transaction and save the game to DB.
2977
+ * Body: { gameId, playerWallet, signature, teamChoice, wagerAmount, gameAddress }
2978
+ * Event data is retrieved server-side from the stash (set during create).
2979
+ */
2980
+ apiRouter.post('/games/confirm', async (req, res) => {
2981
+ try {
2982
+ const {
2983
+ gameId,
2984
+ playerWallet,
2985
+ signature,
2986
+ teamChoice,
2987
+ wagerAmount,
2988
+ role = 'creator',
2989
+ gameAddress,
2990
+ } = req.body;
2991
+
2992
+ if (!gameId) return apiError(res, 400, 'invalid_request', 'gameId is required');
2993
+ if (!playerWallet) return apiError(res, 400, 'invalid_request', 'playerWallet is required');
2994
+ if (!signature) return apiError(res, 400, 'invalid_request', 'signature is required');
2995
+
2996
+ // Retrieve stashed event data from create step (only required for creators)
2997
+ const stashed = popEventData(gameId);
2998
+ if (role === 'creator' && !stashed) {
2999
+ return apiError(res, 400, 'stash_expired', 'Game session expired or was already confirmed. Call /games/create again.');
3000
+ }
3001
+
3002
+ const sportsEvent = stashed?.sportsEvent || {};
3003
+ const gameMode = stashed?.gameMode || 4;
3004
+ const lockTimestamp = stashed?.lockTimestamp || null;
3005
+ const explorerUrl = `https://solscan.io/tx/${signature}`;
3006
+
3007
+ if (role === 'creator') {
3008
+ // Creator: save shared game data + user ref via /save
3009
+ const saveBody = {
3010
+ walletAddress: playerWallet,
3011
+ gameId,
3012
+ sharedGameData: {
3013
+ title: sportsEvent?.strEvent || sportsEvent?.matchName || `Game ${gameId.slice(0, 8)}`,
3014
+ gameType: 'automatic',
3015
+ gameAddress,
3016
+ buyIn: wagerAmount,
3017
+ maxPlayers: 0,
3018
+ gameMode,
3019
+ createdBy: playerWallet,
3020
+ sportsEvent,
3021
+ homeTeamPlayers: teamChoice === 'home' ? [playerWallet] : [],
3022
+ awayTeamPlayers: teamChoice === 'away' ? [playerWallet] : [],
3023
+ drawTeamPlayers: teamChoice === 'draw' ? [playerWallet] : [],
3024
+ lockTimestamp,
3025
+ },
3026
+ userGameRef: {
3027
+ role,
3028
+ joinedAt: new Date().toISOString(),
3029
+ teamChoice,
3030
+ mySignature: signature,
3031
+ myExplorerUrl: explorerUrl,
3032
+ status: 'active',
3033
+ },
3034
+ };
3035
+ await axios.post(`${BASE_URL_INTERNAL}/api/auth/games/save`, saveBody, { timeout: 15000 });
3036
+ } else {
3037
+ // Joiner: use the /:gameId/join endpoint which updates team arrays + pools
3038
+ const joinBody = {
3039
+ walletAddress: playerWallet,
3040
+ teamChoice,
3041
+ amount: wagerAmount,
3042
+ userGameRef: {
3043
+ role: 'player',
3044
+ joinedAt: new Date().toISOString(),
3045
+ teamChoice,
3046
+ mySignature: signature,
3047
+ myExplorerUrl: explorerUrl,
3048
+ status: 'active',
3049
+ },
3050
+ };
3051
+ await axios.post(`${BASE_URL_INTERNAL}/api/auth/games/${gameId}/join`, joinBody, { timeout: 15000 });
3052
+
3053
+ // Auto-lock custom games (game_mode=6) when maxPlayers reached
3054
+ if (gameMode === 6) {
3055
+ const gameCheck = await pool.query('SELECT max_players FROM games WHERE game_id = $1', [gameId]);
3056
+ const mp = gameCheck.rows[0]?.max_players || 0;
3057
+ if (mp > 0) {
3058
+ await autoLockIfFull(gameId, mp);
3059
+ }
3060
+ }
3061
+ }
3062
+
3063
+ // Create developer attribution record for commission tracking
3064
+ const { appId, developerId, commissionWallet } = req.developerApp;
3065
+ await pool.query(`
3066
+ INSERT INTO developer_game_attributions (game_id, app_id, developer_id, commission_wallet)
3067
+ VALUES ($1, $2, $3, $4)
3068
+ ON CONFLICT DO NOTHING
3069
+ `, [gameId, appId, developerId, commissionWallet]);
3070
+
3071
+ console.log(`[DevAPI] Game ${gameId} attributed to app ${appId} (dev ${developerId}, commission → ${commissionWallet})`);
3072
+
3073
+ // Fire webhooks (non-blocking)
3074
+ const webhookPayload = {
3075
+ gameId,
3076
+ gameAddress,
3077
+ playerWallet,
3078
+ teamChoice,
3079
+ wagerAmount,
3080
+ role,
3081
+ signature,
3082
+ explorerUrl,
3083
+ };
3084
+
3085
+ if (role === 'creator') {
3086
+ fireWebhooks(appId, 'game.created', webhookPayload);
3087
+ } else {
3088
+ // Notify the joining player's app
3089
+ fireWebhooks(appId, 'game.joined', webhookPayload);
3090
+
3091
+ // Also notify the game creator's app (may be a different app)
3092
+ pool.query(
3093
+ `SELECT app_id FROM developer_game_attributions WHERE game_id = $1 AND app_id != $2 LIMIT 1`,
3094
+ [gameId, appId]
3095
+ ).then(({ rows }) => {
3096
+ if (rows.length > 0) {
3097
+ fireWebhooks(rows[0].app_id, 'game.joined', webhookPayload);
3098
+ }
3099
+ }).catch(() => {});
3100
+ }
3101
+
3102
+ res.json({
3103
+ success: true,
3104
+ gameId,
3105
+ signature,
3106
+ explorerUrl,
3107
+ message: role === 'creator' ? 'Game created and confirmed' : 'Game joined and confirmed',
3108
+ });
3109
+ } catch (error) {
3110
+ console.error('[DevAPI] Error confirming game:', error.message);
3111
+ apiError(res, 500, 'internal_error', 'Failed to confirm game');
3112
+ }
3113
+ });
3114
+
3115
+ /**
3116
+ * GET /api/developer/v1/games/:gameId
3117
+ * Get game status and details
3118
+ */
3119
+ apiRouter.get('/games/:gameId', async (req, res) => {
3120
+ try {
3121
+ const { gameId } = req.params;
3122
+
3123
+ const result = await pool.query(`
3124
+ SELECT
3125
+ g.game_id,
3126
+ g.game_address,
3127
+ g.title,
3128
+ g.buy_in,
3129
+ g.game_mode,
3130
+ g.is_locked,
3131
+ g.is_resolved,
3132
+ g.automatic_status,
3133
+ g.lock_timestamp,
3134
+ g.home_team_players,
3135
+ g.away_team_players,
3136
+ g.draw_team_players,
3137
+ g.player_amounts,
3138
+ g.home_pool,
3139
+ g.away_pool,
3140
+ g.draw_pool,
3141
+ g.total_pool,
3142
+ g.sports_event,
3143
+ g.matchup_image_url,
3144
+ g.created_at,
3145
+ g.updated_at,
3146
+ g.sports_event->'finalScore'->>'winner' as winner_side
3147
+ FROM games g
3148
+ WHERE g.game_id = $1
3149
+ `, [gameId]);
3150
+
3151
+ if (result.rows.length === 0) {
3152
+ return apiError(res, 404, 'game_not_found', 'Game not found');
3153
+ }
3154
+
3155
+ const game = result.rows[0];
3156
+ const se = game.sports_event || {};
3157
+ const playerAmounts = game.player_amounts || {};
3158
+
3159
+ // Build enriched bettors array — resolve wallets to user profiles
3160
+ const allWallets = [
3161
+ ...(game.home_team_players || []).map(w => ({ wallet: w, team: 'home' })),
3162
+ ...(game.away_team_players || []).map(w => ({ wallet: w, team: 'away' })),
3163
+ ...(game.draw_team_players || []).map(w => ({ wallet: w, team: 'draw' })),
3164
+ ];
3165
+
3166
+ let bettors = [];
3167
+ if (allWallets.length > 0) {
3168
+ const uniqueWallets = [...new Set(allWallets.map(b => b.wallet))];
3169
+ const usersResult = await pool.query(
3170
+ `SELECT wallet_address, username, avatar FROM users WHERE wallet_address = ANY($1)`,
3171
+ [uniqueWallets]
3172
+ );
3173
+ const userMap = {};
3174
+ for (const u of usersResult.rows) {
3175
+ userMap[u.wallet_address] = u;
3176
+ }
3177
+
3178
+ // Fetch claim data from user_game_refs
3179
+ const refsResult = await pool.query(
3180
+ `SELECT wallet_address, amount_claimed, claim_signature FROM user_game_refs WHERE game_id = $1 AND wallet_address = ANY($2)`,
3181
+ [gameId, uniqueWallets]
3182
+ );
3183
+ const refsMap = {};
3184
+ for (const r of refsResult.rows) {
3185
+ refsMap[r.wallet_address] = r;
3186
+ }
3187
+
3188
+ bettors = allWallets.map(b => {
3189
+ const user = userMap[b.wallet] || {};
3190
+ const ref = refsMap[b.wallet] || {};
3191
+ return {
3192
+ wallet: b.wallet,
3193
+ username: user.username || null,
3194
+ avatar: user.avatar || null,
3195
+ team: b.team,
3196
+ amount: parseFloat(playerAmounts[b.wallet]) || parseFloat(game.buy_in),
3197
+ amountClaimed: ref.amount_claimed ? parseFloat(ref.amount_claimed) : null,
3198
+ claimSignature: ref.claim_signature || null,
3199
+ };
3200
+ });
3201
+ }
3202
+
3203
+ res.json({
3204
+ success: true,
3205
+ game: {
3206
+ gameId: game.game_id,
3207
+ gameAddress: game.game_address,
3208
+ title: game.title,
3209
+ buyIn: parseFloat(game.buy_in),
3210
+ gameMode: game.game_mode,
3211
+ isLocked: game.is_locked,
3212
+ isResolved: game.is_resolved,
3213
+ status: publicGameStatus(game.automatic_status),
3214
+ league: se.strLeague || null,
3215
+ lockTimestamp: game.lock_timestamp,
3216
+ opponents: [
3217
+ { name: se.strHomeTeam || null, imageUrl: se.strHomeTeamBadge || null },
3218
+ { name: se.strAwayTeam || null, imageUrl: se.strAwayTeamBadge || null },
3219
+ ],
3220
+ winnerSide: game.winner_side || null,
3221
+ bettors,
3222
+ homePool: parseFloat(game.home_pool) || 0,
3223
+ awayPool: parseFloat(game.away_pool) || 0,
3224
+ drawPool: parseFloat(game.draw_pool) || 0,
3225
+ totalPool: parseFloat(game.total_pool) || 0,
3226
+ media: {
3227
+ poster: game.matchup_image_url || se.strPoster || null,
3228
+ thumbnail: game.matchup_image_url || se.strThumb || null,
3229
+ },
3230
+ createdAt: game.created_at,
3231
+ updatedAt: game.updated_at,
3232
+ },
3233
+ });
3234
+ } catch (error) {
3235
+ console.error('[DevAPI] Error fetching game:', error.message);
3236
+ apiError(res, 500, 'internal_error', 'Failed to fetch game');
3237
+ }
3238
+ });
3239
+
3240
+ /**
3241
+ * GET /api/developer/v1/games/:gameId/live-score
3242
+ * Returns the live ESPN score data for a specific game.
3243
+ */
3244
+ apiRouter.get('/games/:gameId/live-score', async (req, res) => {
3245
+ try {
3246
+ const { gameId } = req.params;
3247
+
3248
+ // 1. Look up the game
3249
+ const result = await pool.query(
3250
+ `SELECT sports_event, game_mode FROM games WHERE game_id = $1`,
3251
+ [gameId]
3252
+ );
3253
+
3254
+ if (result.rows.length === 0) {
3255
+ return apiError(res, 404, 'game_not_found', 'Game not found');
3256
+ }
3257
+
3258
+ const game = result.rows[0];
3259
+ const se = game.sports_event || {};
3260
+ const homeTeam = se.strHomeTeam;
3261
+ const awayTeam = se.strAwayTeam;
3262
+ const league = se.strLeague;
3263
+
3264
+ if (!league || !homeTeam || !awayTeam) {
3265
+ return res.json({ success: true, liveScore: null });
3266
+ }
3267
+
3268
+ // 2. Normalize the league name to ESPN abbreviation
3269
+ const espnLeague = normalizeLeague(league);
3270
+ const url = ESPN_URLS[espnLeague];
3271
+
3272
+ if (!url) {
3273
+ return res.json({ success: true, liveScore: null });
3274
+ }
3275
+
3276
+ // 3. Fetch scores from ESPN
3277
+ let scores;
3278
+ if (espnLeague === 'UFC') {
3279
+ scores = await fetchUFCScores(url);
3280
+ } else {
3281
+ scores = await fetchScoresForLeague(url, espnLeague);
3282
+ }
3283
+
3284
+ // 4. Match by team names (case-insensitive)
3285
+ const homeLower = homeTeam.toLowerCase();
3286
+ const awayLower = awayTeam.toLowerCase();
3287
+
3288
+ const match = scores.find(s => {
3289
+ const names = s.competitors.map(c => c.name.toLowerCase());
3290
+ return names.includes(homeLower) || names.includes(awayLower);
3291
+ });
3292
+
3293
+ if (!match) {
3294
+ return res.json({ success: true, liveScore: null });
3295
+ }
3296
+
3297
+ // 5. Build response
3298
+ const liveScore = {
3299
+ status: match.status,
3300
+ period: match.period || null,
3301
+ displayClock: match.displayClock || null,
3302
+ detail: match.detail || null,
3303
+ shortDetail: match.shortDetail || null,
3304
+ competitors: match.competitors.map(c => ({
3305
+ name: c.name,
3306
+ homeAway: c.homeAway,
3307
+ score: c.score,
3308
+ logo: c.logo || null,
3309
+ abbreviation: c.abbreviation || '',
3310
+ })),
3311
+ };
3312
+
3313
+ // Include UFC-specific data if present
3314
+ if (match.ufcData) {
3315
+ liveScore.ufcData = match.ufcData;
3316
+ }
3317
+
3318
+ res.json({ success: true, liveScore });
3319
+ } catch (error) {
3320
+ console.error('[DevAPI] Error fetching live score:', error.message);
3321
+ apiError(res, 500, 'internal_error', 'Failed to fetch live score');
3322
+ }
3323
+ });
3324
+
3325
+ /**
3326
+ * GET /api/developer/v1/games
3327
+ * List games, optionally filtered by wallet
3328
+ */
3329
+ apiRouter.get('/games', async (req, res) => {
3330
+ try {
3331
+ const { wallet, status, limit = 20, offset = 0 } = req.query;
3332
+ const { appId, networkMode } = req.developerApp;
3333
+
3334
+ const params = [];
3335
+ const conditions = [];
3336
+ let query;
3337
+
3338
+ if (networkMode === 'private') {
3339
+ // Private app: only see games created through THIS app
3340
+ params.push(appId);
3341
+ query = `
3342
+ SELECT
3343
+ g.game_id, g.title, g.buy_in, g.game_mode,
3344
+ g.is_locked, g.is_resolved, g.automatic_status,
3345
+ g.total_pool, g.lock_timestamp,
3346
+ g.sports_event, g.matchup_image_url, g.created_at
3347
+ FROM games g
3348
+ JOIN developer_game_attributions dga ON g.game_id = dga.game_id
3349
+ WHERE dga.app_id = $${params.length}
3350
+ `;
3351
+ } else {
3352
+ // Open app: see games from other open apps + unattributed (main Dubs) + own games
3353
+ params.push(appId);
3354
+ query = `
3355
+ SELECT
3356
+ g.game_id, g.title, g.buy_in, g.game_mode,
3357
+ g.is_locked, g.is_resolved, g.automatic_status,
3358
+ g.total_pool, g.lock_timestamp,
3359
+ g.sports_event, g.matchup_image_url, g.created_at
3360
+ FROM games g
3361
+ LEFT JOIN developer_game_attributions dga ON g.game_id = dga.game_id
3362
+ LEFT JOIN developer_apps da ON dga.app_id = da.id
3363
+ WHERE (
3364
+ da.network_mode = 'open'
3365
+ OR dga.app_id IS NULL
3366
+ OR dga.app_id = $${params.length}
3367
+ )
3368
+ `;
3369
+ }
3370
+
3371
+ if (wallet) {
3372
+ params.push(wallet);
3373
+ conditions.push(`(g.home_team_players @> ARRAY[$${params.length}]::text[] OR g.away_team_players @> ARRAY[$${params.length}]::text[] OR g.draw_team_players @> ARRAY[$${params.length}]::text[])`);
3374
+ }
3375
+
3376
+ if (status) {
3377
+ // Map public API status names to internal DB values
3378
+ const statusMap = { open: 'pending', running: 'pending', locked: 'locked', resolved: 'resolved', pending: 'pending' };
3379
+ const dbStatus = statusMap[status] || status;
3380
+ params.push(dbStatus);
3381
+ conditions.push(`g.automatic_status = $${params.length}`);
3382
+
3383
+ // For "open" status, also exclude games whose lock time has passed
3384
+ if (status === 'open') {
3385
+ conditions.push(`(g.lock_timestamp IS NULL OR g.lock_timestamp > ${Math.floor(Date.now() / 1000)})`);
3386
+ }
3387
+ }
3388
+
3389
+ if (conditions.length > 0) {
3390
+ query += ' AND ' + conditions.join(' AND ');
3391
+ }
3392
+
3393
+ query += ' ORDER BY g.created_at DESC';
3394
+
3395
+ params.push(parseInt(limit));
3396
+ query += ` LIMIT $${params.length}`;
3397
+
3398
+ params.push(parseInt(offset));
3399
+ query += ` OFFSET $${params.length}`;
3400
+
3401
+ const result = await pool.query(query, params);
3402
+
3403
+ res.json({
3404
+ success: true,
3405
+ games: result.rows.map(normalizeGameRow),
3406
+ });
3407
+ } catch (error) {
3408
+ console.error('[DevAPI] Error listing games:', error.message);
3409
+ apiError(res, 500, 'internal_error', 'Failed to list games');
3410
+ }
3411
+ });
3412
+
3413
+ /**
3414
+ * GET /api/developer/v1/network/games
3415
+ * Fetch joinable games from the Dubs open network.
3416
+ * Only available to apps with network_mode = 'open'.
3417
+ * Returns sports games that have at least one empty team slot.
3418
+ */
3419
+ apiRouter.get('/network/games', async (req, res) => {
3420
+ try {
3421
+ const { appId, networkMode } = req.developerApp;
3422
+
3423
+ // Only open-network apps can browse the network
3424
+ if (networkMode === 'private') {
3425
+ return apiError(res, 403, 'network_mode_private', 'Network games are only available to apps with open network mode');
3426
+ }
3427
+
3428
+ const { league, exclude_wallet, limit = 20, offset = 0 } = req.query;
3429
+ const params = [appId];
3430
+ const nowUnix = Math.floor(Date.now() / 1000);
3431
+
3432
+ let query = `
3433
+ SELECT
3434
+ g.game_id, g.title, g.buy_in, g.game_mode,
3435
+ g.is_locked, g.is_resolved, g.automatic_status,
3436
+ g.total_pool, g.lock_timestamp,
3437
+ g.sports_event, g.matchup_image_url, g.created_at
3438
+ FROM games g
3439
+ LEFT JOIN developer_game_attributions dga ON g.game_id = dga.game_id
3440
+ LEFT JOIN developer_apps da ON dga.app_id = da.id
3441
+ WHERE
3442
+ (da.network_mode = 'open' OR dga.app_id IS NULL OR dga.app_id = $1)
3443
+ AND g.game_mode = 4
3444
+ AND g.automatic_status = 'pending'
3445
+ AND g.is_locked = false
3446
+ AND g.is_resolved = false
3447
+ AND (g.lock_timestamp IS NULL OR g.lock_timestamp > ${nowUnix})
3448
+ `;
3449
+
3450
+ // Exclude games the wallet is already in
3451
+ if (exclude_wallet) {
3452
+ params.push(exclude_wallet);
3453
+ query += ` AND NOT (g.home_team_players @> ARRAY[$${params.length}]::text[] OR g.away_team_players @> ARRAY[$${params.length}]::text[] OR g.draw_team_players @> ARRAY[$${params.length}]::text[])`;
3454
+ }
3455
+
3456
+ // Server-side league filter
3457
+ if (league) {
3458
+ const dbLeague = LEAGUE_ABBREV_TO_DB[league.toUpperCase()];
3459
+ if (dbLeague) {
3460
+ params.push(`%${dbLeague}%`);
3461
+ query += ` AND g.sports_event->>'strLeague' ILIKE $${params.length}`;
3462
+ }
3463
+ }
3464
+
3465
+ // Count for pagination (before LIMIT/OFFSET)
3466
+ const countQuery = `SELECT COUNT(*) FROM (${query}) sub`;
3467
+ const countResult = await pool.query(countQuery, params);
3468
+ const total = parseInt(countResult.rows[0].count);
3469
+
3470
+ query += ' ORDER BY g.created_at DESC';
3471
+
3472
+ params.push(parseInt(limit));
3473
+ query += ` LIMIT $${params.length}`;
3474
+
3475
+ params.push(parseInt(offset));
3476
+ query += ` OFFSET $${params.length}`;
3477
+
3478
+ const result = await pool.query(query, params);
3479
+
3480
+ res.json({
3481
+ success: true,
3482
+ games: result.rows.map(normalizeGameRow),
3483
+ pagination: {
3484
+ total,
3485
+ limit: parseInt(limit),
3486
+ offset: parseInt(offset),
3487
+ },
3488
+ });
3489
+ } catch (error) {
3490
+ console.error('[DevAPI] Error listing network games:', error.message);
3491
+ apiError(res, 500, 'internal_error', 'Failed to list network games');
3492
+ }
3493
+ });
3494
+
3495
+ /**
3496
+ * POST /api/developer/v1/transactions/build/claim
3497
+ * Build an unsigned claim transaction for a resolved game
3498
+ */
3499
+ apiRouter.post('/transactions/build/claim', async (req, res) => {
3500
+ try {
3501
+ const { playerWallet, gameId } = req.body;
3502
+
3503
+ if (!playerWallet) return apiError(res, 400, 'invalid_request', 'playerWallet is required');
3504
+ if (!gameId) return apiError(res, 400, 'invalid_request', 'gameId is required');
3505
+
3506
+ const txResponse = await axios.post(
3507
+ `${BASE_URL_INTERNAL}/api/v1/prod/transaction/build/claim-automatic`,
3508
+ { playerAddress: playerWallet, gameId },
3509
+ { timeout: 15000 }
3510
+ );
3511
+
3512
+ const base64Tx = txResponse.data.transaction;
3513
+
3514
+ const ok = await simulateTransactionOrFail(base64Tx, res, 'Claim');
3515
+ if (!ok) return;
3516
+
3517
+ res.json({
3518
+ success: true,
3519
+ transaction: base64Tx,
3520
+ gameAddress: txResponse.data.gameAddress,
3521
+ message: 'Have your user sign this transaction to claim winnings',
3522
+ });
3523
+ } catch (error) {
3524
+ console.error('[DevAPI] Error building claim tx:', error.message);
3525
+ // Pass through error from internal API
3526
+ if (error.response?.data) {
3527
+ const d = error.response.data;
3528
+ const code = d.error?.code || d.code || 'transaction_failed';
3529
+ const message = d.error?.message || d.message || 'Failed to build claim transaction';
3530
+ return apiError(res, error.response.status || 400, code, message);
3531
+ }
3532
+ apiError(res, 500, 'internal_error', 'Failed to build claim transaction');
3533
+ }
3534
+ });
3535
+
3536
+ /**
3537
+ * GET /api/developer/v1/apps/config
3538
+ * Returns the app's UI customization config (accent color, icon, tagline)
3539
+ * Used by the SDK on DubsProvider mount to apply developer branding
3540
+ */
3541
+ apiRouter.get('/apps/config', apiKeyAuth, async (req, res) => {
3542
+ try {
3543
+ const { rows } = await pool.query(
3544
+ 'SELECT ui_config, website_url, app_name FROM developer_apps WHERE id = $1',
3545
+ [req.developerApp.appId]
3546
+ );
3547
+ const app = rows[0] || {};
3548
+ res.json({
3549
+ success: true,
3550
+ data: {
3551
+ uiConfig: {
3552
+ ...app.ui_config,
3553
+ appUrl: app.website_url || undefined,
3554
+ appName: app.ui_config?.appName || app.app_name || undefined,
3555
+ }
3556
+ }
3557
+ });
3558
+ } catch (error) {
3559
+ console.error('[DevAPI] Error fetching app config:', error.message);
3560
+ apiError(res, 500, 'internal_error', 'Failed to fetch app config');
3561
+ }
3562
+ });
3563
+
3564
+ /**
3565
+ * POST /api/developer/v1/errors/parse
3566
+ * Decode a raw Solana transaction error into a human-readable { code, message }
3567
+ */
3568
+ apiRouter.post('/errors/parse', (req, res) => {
3569
+ const { error } = req.body;
3570
+ if (!error) return apiError(res, 400, 'invalid_request', 'error field is required');
3571
+ const parsed = parseSolanaError(error);
3572
+ res.json({ success: true, error: parsed });
3573
+ });
3574
+
3575
+ /**
3576
+ * GET /api/developer/v1/errors/codes
3577
+ * Return the full map of Solana program error codes for client-side use
3578
+ */
3579
+ apiRouter.get('/errors/codes', (_req, res) => {
3580
+ res.json({ success: true, errors: SOLANA_PROGRAM_ERRORS });
3581
+ });
3582
+
3583
+ // ============================================================
3584
+ // CUSTOM GAME ENDPOINTS (game_mode=6)
3585
+ // Developer-resolved games — no sports/esports event needed
3586
+ // ============================================================
3587
+
3588
+ /**
3589
+ * POST /api/developer/v1/games/custom/create
3590
+ * Create a custom wager game. Returns unsigned Solana transaction.
3591
+ *
3592
+ * Body: {
3593
+ * playerWallet, teamChoice, wagerAmount, title,
3594
+ * lockTimestamp? (ISO string or unix — defaults to now+30days),
3595
+ * maxPlayers? (0=unlimited, default),
3596
+ * metadata? (arbitrary developer data)
3597
+ * }
3598
+ */
3599
+ apiRouter.post('/games/custom/create', async (req, res) => {
3600
+ try {
3601
+ const {
3602
+ playerWallet,
3603
+ teamChoice,
3604
+ wagerAmount,
3605
+ title,
3606
+ lockTimestamp: rawLockTimestamp,
3607
+ maxPlayers = 0,
3608
+ metadata,
3609
+ } = req.body;
3610
+
3611
+ if (!playerWallet) return apiError(res, 400, 'invalid_request', 'playerWallet is required');
3612
+ if (!teamChoice) return apiError(res, 400, 'invalid_request', 'teamChoice is required');
3613
+ if (!wagerAmount) return apiError(res, 400, 'invalid_request', 'wagerAmount is required');
3614
+ if (!['home', 'away', 'draw'].includes(teamChoice)) {
3615
+ return apiError(res, 400, 'invalid_team_choice', 'teamChoice must be home, away, or draw');
3616
+ }
3617
+ if (typeof maxPlayers !== 'number' || maxPlayers < 0 || maxPlayers > 50) {
3618
+ return apiError(res, 400, 'invalid_max_players', 'maxPlayers must be 0-50 (0 = unlimited)');
3619
+ }
3620
+
3621
+ // Resolve lockTimestamp
3622
+ // Default to now + 30 days (same pattern as Connect4).
3623
+ // Custom games don't have a real "game start" time — the developer controls
3624
+ // when to resolve via the resolve endpoint. The force-lock + resolve pattern
3625
+ // allows immediate resolution regardless of lock time.
3626
+ // Developers can override via the lockTimestamp param if needed.
3627
+ let lockTimestamp;
3628
+ if (rawLockTimestamp) {
3629
+ // Accept ISO string or Unix seconds
3630
+ lockTimestamp = typeof rawLockTimestamp === 'string'
3631
+ ? Math.floor(new Date(rawLockTimestamp).getTime() / 1000)
3632
+ : Math.floor(rawLockTimestamp);
3633
+ } else {
3634
+ lockTimestamp = Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60); // 30 days
3635
+ }
3636
+
3637
+ // Validate lockTimestamp is at least 2 minutes in the future (contract enforces this at init)
3638
+ const minLock = Math.floor(Date.now() / 1000) + 110; // small buffer
3639
+ if (lockTimestamp < minLock) {
3640
+ return apiError(res, 400, 'invalid_lock_timestamp', 'lockTimestamp must be at least 2 minutes in the future');
3641
+ }
3642
+
3643
+ const gameTitle = title || `Custom Game`;
3644
+
3645
+ // Generate game ID — custom prefix for easy identification
3646
+ const gameId = crypto.randomUUID();
3647
+
3648
+ // Use the game title as the on-chain sportsEventId (it's just a string label)
3649
+ const sportsEventId = `custom:${req.developerApp.appId}:${gameId}`;
3650
+
3651
+ // Build unsigned transaction via the same internal builder
3652
+ const txResponse = await axios.post(
3653
+ `${BASE_URL_INTERNAL}/api/v1/prod/transaction/build/create-and-join-automatic`,
3654
+ {
3655
+ creatorAddress: playerWallet,
3656
+ buyIn: wagerAmount,
3657
+ lockTimestamp,
3658
+ sportsEventId,
3659
+ teamChoice,
3660
+ gameId,
3661
+ },
3662
+ { timeout: 15000 }
3663
+ );
3664
+
3665
+ if (!txResponse.data.success) {
3666
+ return apiError(res, 400, 'transaction_failed', 'Failed to build transaction');
3667
+ }
3668
+
3669
+ // Stash custom game data for the confirm step
3670
+ stashEventData(txResponse.data.gameId, {
3671
+ sportsEvent: {
3672
+ strEvent: gameTitle,
3673
+ strHomeTeam: 'Home',
3674
+ strAwayTeam: 'Away',
3675
+ strLeague: 'Custom',
3676
+ ...(metadata || {}),
3677
+ },
3678
+ gameMode: 6,
3679
+ lockTimestamp,
3680
+ maxPlayers,
3681
+ metadata: metadata || null,
3682
+ title: gameTitle,
3683
+ });
3684
+
3685
+ const ok = await simulateTransactionOrFail(txResponse.data.transaction, res, 'CustomCreate');
3686
+ if (!ok) return;
3687
+
3688
+ res.json({
3689
+ success: true,
3690
+ gameId: txResponse.data.gameId,
3691
+ gameAddress: txResponse.data.gameAddress,
3692
+ transaction: txResponse.data.transaction,
3693
+ lockTimestamp,
3694
+ });
3695
+ } catch (error) {
3696
+ console.error('[DevAPI] Error creating custom game:', error.message);
3697
+ apiError(res, 500, 'internal_error', 'Failed to create custom game');
3698
+ }
3699
+ });
3700
+
3701
+ /**
3702
+ * POST /api/developer/v1/games/custom/confirm
3703
+ * Confirm a signed custom game transaction and save to DB.
3704
+ *
3705
+ * Body: {
3706
+ * gameId, playerWallet, signature, teamChoice, wagerAmount,
3707
+ * role? ('creator'|'player'), gameAddress
3708
+ * }
3709
+ */
3710
+ apiRouter.post('/games/custom/confirm', async (req, res) => {
3711
+ try {
3712
+ const {
3713
+ gameId,
3714
+ playerWallet,
3715
+ signature,
3716
+ teamChoice,
3717
+ wagerAmount,
3718
+ role = 'creator',
3719
+ gameAddress,
3720
+ } = req.body;
3721
+
3722
+ if (!gameId) return apiError(res, 400, 'invalid_request', 'gameId is required');
3723
+ if (!playerWallet) return apiError(res, 400, 'invalid_request', 'playerWallet is required');
3724
+ if (!signature) return apiError(res, 400, 'invalid_request', 'signature is required');
3725
+
3726
+ // Retrieve stashed custom game data
3727
+ const stashed = popEventData(gameId);
3728
+ if (role === 'creator' && !stashed) {
3729
+ return apiError(res, 400, 'stash_expired', 'Game session expired or was already confirmed. Call /games/custom/create again.');
3730
+ }
3731
+
3732
+ const sportsEvent = stashed?.sportsEvent || {};
3733
+ const gameMode = 6;
3734
+ const lockTimestamp = stashed?.lockTimestamp || null;
3735
+ const maxPlayers = stashed?.maxPlayers || 0;
3736
+ const gameTitle = stashed?.title || sportsEvent?.strEvent || `Custom Game ${gameId.slice(0, 8)}`;
3737
+
3738
+ const explorerUrl = `https://solscan.io/tx/${signature}`;
3739
+
3740
+ if (role === 'creator') {
3741
+ const saveBody = {
3742
+ walletAddress: playerWallet,
3743
+ gameId,
3744
+ sharedGameData: {
3745
+ title: gameTitle,
3746
+ gameType: 'automatic',
3747
+ gameAddress,
3748
+ buyIn: wagerAmount,
3749
+ maxPlayers,
3750
+ gameMode,
3751
+ createdBy: playerWallet,
3752
+ sportsEvent,
3753
+ homeTeamPlayers: teamChoice === 'home' ? [playerWallet] : [],
3754
+ awayTeamPlayers: teamChoice === 'away' ? [playerWallet] : [],
3755
+ drawTeamPlayers: teamChoice === 'draw' ? [playerWallet] : [],
3756
+ lockTimestamp,
3757
+ },
3758
+ userGameRef: {
3759
+ role,
3760
+ joinedAt: new Date().toISOString(),
3761
+ teamChoice,
3762
+ mySignature: signature,
3763
+ myExplorerUrl: explorerUrl,
3764
+ status: 'active',
3765
+ },
3766
+ };
3767
+ await axios.post(`${BASE_URL_INTERNAL}/api/auth/games/save`, saveBody, { timeout: 15000 });
3768
+ } else {
3769
+ const joinBody = {
3770
+ walletAddress: playerWallet,
3771
+ teamChoice,
3772
+ amount: wagerAmount,
3773
+ userGameRef: {
3774
+ role: 'player',
3775
+ joinedAt: new Date().toISOString(),
3776
+ teamChoice,
3777
+ mySignature: signature,
3778
+ myExplorerUrl: explorerUrl,
3779
+ status: 'active',
3780
+ },
3781
+ };
3782
+ await axios.post(`${BASE_URL_INTERNAL}/api/auth/games/${gameId}/join`, joinBody, { timeout: 15000 });
3783
+
3784
+ // Auto-lock if maxPlayers reached (game_mode=6 feature)
3785
+ if (maxPlayers > 0) {
3786
+ await autoLockIfFull(gameId, maxPlayers);
3787
+ }
3788
+ }
3789
+
3790
+ // Attribution
3791
+ const { appId, developerId, commissionWallet } = req.developerApp;
3792
+ await pool.query(`
3793
+ INSERT INTO developer_game_attributions (game_id, app_id, developer_id, commission_wallet)
3794
+ VALUES ($1, $2, $3, $4)
3795
+ ON CONFLICT DO NOTHING
3796
+ `, [gameId, appId, developerId, commissionWallet]);
3797
+
3798
+ console.log(`[DevAPI] Custom game ${gameId} attributed to app ${appId}`);
3799
+
3800
+ // Fire webhooks (non-blocking)
3801
+ const webhookPayload = { gameId, gameAddress, playerWallet, teamChoice, wagerAmount, role, signature, explorerUrl };
3802
+ if (role === 'creator') {
3803
+ fireWebhooks(appId, 'game.created', webhookPayload);
3804
+ } else {
3805
+ fireWebhooks(appId, 'game.joined', webhookPayload);
3806
+ pool.query(
3807
+ `SELECT app_id FROM developer_game_attributions WHERE game_id = $1 AND app_id != $2 LIMIT 1`,
3808
+ [gameId, appId]
3809
+ ).then(({ rows }) => {
3810
+ if (rows.length > 0) fireWebhooks(rows[0].app_id, 'game.joined', webhookPayload);
3811
+ }).catch(() => {});
3812
+ }
3813
+
3814
+ res.json({
3815
+ success: true,
3816
+ gameId,
3817
+ signature,
3818
+ explorerUrl,
3819
+ message: role === 'creator' ? 'Custom game created and confirmed' : 'Custom game joined and confirmed',
3820
+ });
3821
+ } catch (error) {
3822
+ console.error('[DevAPI] Error confirming custom game:', error.message);
3823
+ apiError(res, 500, 'internal_error', 'Failed to confirm custom game');
3824
+ }
3825
+ });
3826
+
3827
+ /**
3828
+ * POST /api/developer/v1/games/:gameId/claim/confirm
3829
+ * Confirm a signed claim transaction and record it in the DB.
3830
+ *
3831
+ * Called by the SDK after the user signs and sends the on-chain claim tx.
3832
+ *
3833
+ * Body: { playerWallet, signature, amountClaimed }
3834
+ */
3835
+ apiRouter.post('/games/:gameId/claim/confirm', async (req, res) => {
3836
+ try {
3837
+ const { gameId } = req.params;
3838
+ const { playerWallet, signature, amountClaimed } = req.body;
3839
+
3840
+ if (!gameId) return apiError(res, 400, 'invalid_request', 'gameId is required');
3841
+ if (!playerWallet) return apiError(res, 400, 'invalid_request', 'playerWallet is required');
3842
+ if (!signature) return apiError(res, 400, 'invalid_request', 'signature is required');
3843
+
3844
+ const explorerUrl = `https://solscan.io/tx/${signature}`;
3845
+
3846
+ // Update user_game_refs with claim information
3847
+ const result = await pool.query(`
3848
+ UPDATE user_game_refs
3849
+ SET claimed_at = NOW(),
3850
+ claim_signature = $1,
3851
+ claim_explorer_url = $2,
3852
+ amount_claimed = $3,
3853
+ updated_at = NOW()
3854
+ WHERE wallet_address = $4 AND game_id = $5
3855
+ RETURNING *
3856
+ `, [signature, explorerUrl, amountClaimed || null, playerWallet, gameId]);
3857
+
3858
+ if (result.rows.length === 0) {
3859
+ return apiError(res, 404, 'not_found', 'No game reference found for this player and game');
3860
+ }
3861
+
3862
+ console.log(`[DevAPI] Claim confirmed: game=${gameId} player=${playerWallet} sig=${signature.slice(0, 12)}... amount=${amountClaimed}`);
3863
+
3864
+ // Fire game.claimed webhook (non-blocking)
3865
+ const { appId } = req.developerApp;
3866
+ fireWebhooks(appId, 'game.claimed', {
3867
+ gameId,
3868
+ playerWallet,
3869
+ signature,
3870
+ explorerUrl,
3871
+ amountClaimed: amountClaimed || null,
3872
+ });
3873
+
3874
+ res.json({
3875
+ success: true,
3876
+ gameId,
3877
+ signature,
3878
+ explorerUrl,
3879
+ message: 'Claim confirmed',
3880
+ });
3881
+ } catch (error) {
3882
+ console.error('[DevAPI] Error confirming claim:', error.message);
3883
+ apiError(res, 500, 'internal_error', 'Failed to confirm claim');
3884
+ }
3885
+ });
3886
+
3887
+ /**
3888
+ * POST /api/developer/v1/games/:gameId/resolve
3889
+ * Developer webhook to resolve a custom game (game_mode=6).
3890
+ *
3891
+ * Security: API key + HMAC signature + app ownership + state validation
3892
+ *
3893
+ * Headers:
3894
+ * x-api-key: dubs_test_...
3895
+ * x-dubs-signature: sha256=<HMAC-SHA256(body, resolution_secret)>
3896
+ *
3897
+ * Body: {
3898
+ * winner: 'home' | 'away' | 'draw' | null, // null = refund
3899
+ * metadata?: { ... } // optional proof data
3900
+ * }
3901
+ */
3902
+ apiRouter.post('/games/:gameId/resolve', async (req, res) => {
3903
+ try {
3904
+ const { gameId } = req.params;
3905
+ const { winner, metadata } = req.body;
3906
+
3907
+ // 1. Validate winner value
3908
+ if (winner === undefined) {
3909
+ return apiError(res, 400, 'invalid_request', 'winner is required (use null for refund)');
3910
+ }
3911
+ if (winner !== null && !['home', 'away', 'draw'].includes(winner)) {
3912
+ return apiError(res, 400, 'invalid_winner', 'winner must be home, away, draw, or null');
3913
+ }
3914
+
3915
+ // 2. Verify HMAC signature
3916
+ const signatureHeader = req.headers['x-dubs-signature'];
3917
+ if (!signatureHeader) {
3918
+ return apiError(res, 403, 'missing_signature', 'x-dubs-signature header is required');
3919
+ }
3920
+
3921
+ // Look up the app's resolution_secret
3922
+ const appResult = await pool.query(
3923
+ 'SELECT resolution_secret FROM developer_apps WHERE id = $1',
3924
+ [req.developerApp.appId]
3925
+ );
3926
+ const resolutionSecret = appResult.rows[0]?.resolution_secret;
3927
+ if (!resolutionSecret) {
3928
+ return apiError(res, 403, 'no_resolution_secret', 'App has no resolution secret configured. Contact support.');
3929
+ }
3930
+
3931
+ // Verify HMAC: sha256=<hex>
3932
+ const rawBody = req.rawBody;
3933
+ if (!rawBody) {
3934
+ return apiError(res, 400, 'missing_raw_body', 'Request body could not be verified. Ensure Content-Type is application/json.');
3935
+ }
3936
+
3937
+ const expectedSig = 'sha256=' + crypto.createHmac('sha256', resolutionSecret).update(rawBody).digest('hex');
3938
+ if (!crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expectedSig))) {
3939
+ return apiError(res, 403, 'invalid_signature', 'HMAC signature verification failed');
3940
+ }
3941
+
3942
+ // 3. Load game and validate state
3943
+ const gameResult = await pool.query(`
3944
+ SELECT game_id, game_mode, is_resolved, is_locked, automatic_status,
3945
+ home_team_players, away_team_players, draw_team_players
3946
+ FROM games WHERE game_id = $1
3947
+ `, [gameId]);
3948
+
3949
+ if (gameResult.rows.length === 0) {
3950
+ return apiError(res, 404, 'game_not_found', 'Game not found');
3951
+ }
3952
+
3953
+ const game = gameResult.rows[0];
3954
+
3955
+ if (game.game_mode !== 6) {
3956
+ return apiError(res, 400, 'wrong_game_mode', 'Only custom games (game_mode=6) can be resolved via this endpoint');
3957
+ }
3958
+
3959
+ if (game.is_resolved) {
3960
+ return apiError(res, 409, 'already_resolved', 'Game has already been resolved');
3961
+ }
3962
+
3963
+ // 4. Verify app ownership via developer_game_attributions
3964
+ const attrResult = await pool.query(
3965
+ 'SELECT id FROM developer_game_attributions WHERE game_id = $1 AND app_id = $2',
3966
+ [gameId, req.developerApp.appId]
3967
+ );
3968
+ if (attrResult.rows.length === 0) {
3969
+ return apiError(res, 403, 'not_your_game', 'This game does not belong to your app');
3970
+ }
3971
+
3972
+ // 5. Auto-lock if not locked yet
3973
+ if (!game.is_locked) {
3974
+ await pool.query(
3975
+ `UPDATE games SET is_locked = true, automatic_status = 'locked', updated_at = NOW() WHERE game_id = $1`,
3976
+ [gameId]
3977
+ );
3978
+ console.log(`[DevAPI] Auto-locked game ${gameId} before resolution`);
3979
+ }
3980
+
3981
+ // 6. Check for competition — if only one side has bets, force refund
3982
+ const homePlayers = game.home_team_players || [];
3983
+ const awayPlayers = game.away_team_players || [];
3984
+ const drawPlayers = game.draw_team_players || [];
3985
+ const sidesWithBets = [homePlayers.length > 0, awayPlayers.length > 0, drawPlayers.length > 0].filter(Boolean).length;
3986
+
3987
+ let effectiveWinner = winner;
3988
+ if (sidesWithBets < 2) {
3989
+ effectiveWinner = null; // Force refund — no competition
3990
+ console.log(`[DevAPI] Game ${gameId} has only ${sidesWithBets} side(s) with bets — forcing refund`);
3991
+ }
3992
+
3993
+ // 7. Resolve on-chain + update DB
3994
+ const resolver = getCustomGameResolver();
3995
+ const { signature: txSignature } = await resolver.resolveGame(gameId, effectiveWinner, metadata);
3996
+
3997
+ // 8. Fire webhook notification
3998
+ fireWebhooks(req.developerApp.appId, 'game.resolved', {
3999
+ gameId,
4000
+ winner: effectiveWinner,
4001
+ signature: txSignature,
4002
+ metadata: metadata || null,
4003
+ });
4004
+
4005
+ res.json({
4006
+ success: true,
4007
+ gameId,
4008
+ winner: effectiveWinner,
4009
+ signature: txSignature,
4010
+ explorerUrl: `https://solscan.io/tx/${txSignature}`,
4011
+ });
4012
+ } catch (error) {
4013
+ console.error('[DevAPI] Error resolving custom game:', error.message);
4014
+
4015
+ let userMessage = error.message;
4016
+ const hexMatch = error.message.match(/custom program error: 0x([0-9a-fA-F]+)/);
4017
+ if (hexMatch) {
4018
+ const errorCode = parseInt(hexMatch[1], 16);
4019
+ const known = SOLANA_PROGRAM_ERRORS[errorCode];
4020
+ if (known) userMessage = known.message;
4021
+ }
4022
+ if (error.transactionError) {
4023
+ const parsed = parseSolanaError(error.transactionError);
4024
+ if (parsed && parsed.code !== 'unknown_error') userMessage = parsed.message;
4025
+ }
4026
+
4027
+ apiError(res, 500, 'resolution_failed', userMessage);
4028
+ }
4029
+ });
4030
+
4031
+ /**
4032
+ * Auto-lock a game if it has reached maxPlayers.
4033
+ * Used for game_mode=6 custom games (e.g., 1v1 Battleship).
4034
+ */
4035
+ async function autoLockIfFull(gameId, maxPlayers) {
4036
+ try {
4037
+ const result = await pool.query(`
4038
+ SELECT home_team_players, away_team_players, draw_team_players, is_locked
4039
+ FROM games WHERE game_id = $1
4040
+ `, [gameId]);
4041
+
4042
+ if (result.rows.length === 0) return;
4043
+ const game = result.rows[0];
4044
+ if (game.is_locked) return;
4045
+
4046
+ const totalPlayers = (game.home_team_players || []).length
4047
+ + (game.away_team_players || []).length
4048
+ + (game.draw_team_players || []).length;
4049
+
4050
+ if (totalPlayers >= maxPlayers) {
4051
+ await pool.query(
4052
+ `UPDATE games SET is_locked = true, automatic_status = 'locked', updated_at = NOW() WHERE game_id = $1`,
4053
+ [gameId]
4054
+ );
4055
+ console.log(`[DevAPI] Auto-locked game ${gameId}: ${totalPlayers}/${maxPlayers} players`);
4056
+ }
4057
+ } catch (err) {
4058
+ console.error(`[DevAPI] Error in autoLockIfFull for ${gameId}:`, err.message);
4059
+ }
4060
+ }
4061
+
4062
+ // ============================================================
4063
+ // UFC FIGHT CARD
4064
+ // ============================================================
4065
+
4066
+ /**
4067
+ * GET /api/developer/v1/ufc/fightcard
4068
+ * Returns the full upcoming/live UFC fight card with all fights.
4069
+ *
4070
+ * Each fight includes fighters, weight class, round info, and live status.
4071
+ * Fights are grouped by eventName so consumers can display the full card.
4072
+ *
4073
+ * Response shape:
4074
+ * {
4075
+ * success: true,
4076
+ * events: [{
4077
+ * eventName: "UFC 324: ...",
4078
+ * fights: [{ home, away, weightClass, status, ufcData, ... }]
4079
+ * }]
4080
+ * }
4081
+ */
4082
+ apiRouter.get('/ufc/fightcard', async (req, res) => {
4083
+ try {
4084
+ // Fetch upcoming UFC events for the next 90 days (default scoreboard only returns current/next event)
4085
+ const now = new Date();
4086
+ const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
4087
+ const startDate = yesterday.toISOString().split('T')[0].replace(/-/g, '');
4088
+ const endDate = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000)
4089
+ .toISOString().split('T')[0].replace(/-/g, '');
4090
+ const url = `${ESPN_URLS['UFC']}?dates=${startDate}-${endDate}`;
4091
+ const rawFights = await fetchUFCScores(url);
4092
+
4093
+ // Group fights by eventName
4094
+ const eventMap = new Map();
4095
+ for (const fight of rawFights) {
4096
+ const key = fight.eventName || 'UFC Event';
4097
+ if (!eventMap.has(key)) {
4098
+ eventMap.set(key, { eventName: key, date: fight.date, fights: [] });
4099
+ }
4100
+ const home = fight.competitors.find(c => c.homeAway === 'home') || fight.competitors[0];
4101
+ const away = fight.competitors.find(c => c.homeAway === 'away') || fight.competitors[1];
4102
+ eventMap.get(key).fights.push({
4103
+ id: fight.competitionId || null,
4104
+ home: {
4105
+ name: home?.name || 'TBD',
4106
+ athleteId: home?.athleteId || null,
4107
+ headshotUrl: home?.headshot || null,
4108
+ flagUrl: home?.logo || null,
4109
+ country: home?.country || null,
4110
+ abbreviation: home?.abbreviation || '',
4111
+ record: home?.record || null,
4112
+ winner: home?.winner || false,
4113
+ },
4114
+ away: {
4115
+ name: away?.name || 'TBD',
4116
+ athleteId: away?.athleteId || null,
4117
+ headshotUrl: away?.headshot || null,
4118
+ flagUrl: away?.logo || null,
4119
+ country: away?.country || null,
4120
+ abbreviation: away?.abbreviation || '',
4121
+ record: away?.record || null,
4122
+ winner: away?.winner || false,
4123
+ },
4124
+ weightClass: fight.weightClass || null,
4125
+ status: fight.status,
4126
+ ufcData: fight.ufcData || null,
4127
+ });
4128
+ }
4129
+
4130
+ const events = Array.from(eventMap.values());
4131
+
4132
+ res.json({ success: true, events });
4133
+ } catch (error) {
4134
+ console.error('[DevAPI] Error fetching UFC fight card:', error.message);
4135
+ apiError(res, 500, 'internal_error', 'Failed to fetch UFC fight card');
4136
+ }
4137
+ });
4138
+
4139
+ // ============================================================
4140
+ // UFC FIGHTER DETAIL
4141
+ // ============================================================
4142
+
4143
+ /**
4144
+ * GET /api/developer/v1/ufc/fighters/:athleteId
4145
+ * Returns detailed fighter profile from ESPN core API.
4146
+ *
4147
+ * Includes: nickname, height, weight, reach, stance, gym, stance photo,
4148
+ * citizenship, weight class, date of birth, and age.
4149
+ *
4150
+ * The athleteId comes from the fightcard endpoint (home.athleteId / away.athleteId).
4151
+ */
4152
+ apiRouter.get('/ufc/fighters/:athleteId', async (req, res) => {
4153
+ try {
4154
+ const { athleteId } = req.params;
4155
+ if (!athleteId) {
4156
+ return apiError(res, 400, 'missing_param', 'athleteId is required');
4157
+ }
4158
+
4159
+ const espnUrl = `https://sports.core.api.espn.com/v2/sports/mma/leagues/ufc/athletes/${athleteId}`;
4160
+ const { data } = await axios.get(espnUrl, { timeout: 10000 });
4161
+
4162
+ const fighter = {
4163
+ athleteId: data.id,
4164
+ firstName: data.firstName || null,
4165
+ lastName: data.lastName || null,
4166
+ fullName: data.fullName || data.displayName || null,
4167
+ nickname: data.nickname || null,
4168
+ shortName: data.shortName || null,
4169
+ height: data.displayHeight || null,
4170
+ heightInches: data.height || null,
4171
+ weight: data.displayWeight || null,
4172
+ weightLbs: data.weight || null,
4173
+ reach: data.displayReach || null,
4174
+ reachInches: data.reach || null,
4175
+ age: data.age || null,
4176
+ dateOfBirth: data.dateOfBirth || null,
4177
+ stance: data.stance?.text || null,
4178
+ weightClass: data.weightClass?.text || null,
4179
+ citizenship: data.citizenship || null,
4180
+ citizenshipAbbreviation: data.citizenshipCountry?.abbreviation || null,
4181
+ gym: data.association?.name?.trim() || null,
4182
+ gymCountry: data.association?.location?.country || null,
4183
+ active: data.active || false,
4184
+ headshotUrl: data.headshot?.href || null,
4185
+ flagUrl: data.flag?.href || null,
4186
+ stanceImageUrl: data.images?.find(img => img.rel?.includes('rightStance'))?.href || null,
4187
+ espnUrl: data.links?.find(l => l.rel?.includes('playercard'))?.href || null,
4188
+ slug: data.slug || null,
4189
+ };
4190
+
4191
+ res.json({ success: true, fighter });
4192
+ } catch (error) {
4193
+ if (error.response?.status === 404) {
4194
+ return apiError(res, 404, 'not_found', `Fighter with athleteId ${req.params.athleteId} not found`);
4195
+ }
4196
+ console.error('[DevAPI] Error fetching UFC fighter:', error.message);
4197
+ apiError(res, 500, 'internal_error', 'Failed to fetch fighter details');
4198
+ }
4199
+ });
4200
+
4201
+ module.exports = { portalRouter, apiRouter };