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