dubs-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/.claude/settings.local.json +280 -0
  2. package/CLAUDE.md +46 -0
  3. package/CONNECT4_PRODUCTION_DEPLOY.md +155 -0
  4. package/CURRENT_SESSION.md +171 -0
  5. package/CURRENT_SESSION_DRAW.md +516 -0
  6. package/MARCH_MADNESS_SURVIVOR.md +254 -0
  7. package/PANDA.md +166 -0
  8. package/Procfile +4 -0
  9. package/README.md +476 -0
  10. package/controllers/livescoresController.js +376 -0
  11. package/controllers/pickemController.js +554 -0
  12. package/controllers/survivorAdminController.js +887 -0
  13. package/controllers/survivorController.js +623 -0
  14. package/cron/oracleMonitor.js +77 -0
  15. package/cron/pickemOracleMonitor.js +73 -0
  16. package/data/jackpot-history.json +952 -0
  17. package/data/ncaaTeams.js +406 -0
  18. package/documentation/API_SECURITY_GUIDE.md +327 -0
  19. package/documentation/ARCADE_API.md +593 -0
  20. package/documentation/ARCADE_IMPLEMENTATION_SUMMARY.md +399 -0
  21. package/documentation/ARCADE_QUICKSTART.md +242 -0
  22. package/documentation/AUTOMATIC_MODE_ORACLE.md +321 -0
  23. package/documentation/BUG_FIX_COHORT_DATE_DISPLAY.md +171 -0
  24. package/documentation/CLAIM_MIGRATION_INSTRUCTIONS.md +52 -0
  25. package/documentation/CLAIM_STATUS_FIX.md +67 -0
  26. package/documentation/CLI_TOOL_GUIDE.md +372 -0
  27. package/documentation/COHORT_RETENTION_ANALYSIS.md +295 -0
  28. package/documentation/COHORT_RETENTION_IMPLEMENTATION_COMPLETE.md +461 -0
  29. package/documentation/COHORT_RETENTION_SUMMARY.md +204 -0
  30. package/documentation/COMPLETE_PROJECT_SUMMARY.md +490 -0
  31. package/documentation/DATABASE_QUERIES.md +269 -0
  32. package/documentation/DATABASE_RETENTION_POLICY.md +390 -0
  33. package/documentation/DATABASE_SETUP_GUIDE.md +361 -0
  34. package/documentation/DATABASE_SETUP_SUMMARY.md +247 -0
  35. package/documentation/DEMO_API_CURL_COMMANDS.md +656 -0
  36. package/documentation/DEPLOYMENT_SUMMARY.txt +100 -0
  37. package/documentation/DUPLICATE_NOTIFICATIONS_FIXED.md +201 -0
  38. package/documentation/EXCHANGE_RATES_INTEGRATION.md +371 -0
  39. package/documentation/FINAL_API_PROTECTION_TABLE.md +175 -0
  40. package/documentation/GAME_START_NOTIFICATIONS_DEPLOYMENT.md +256 -0
  41. package/documentation/GAME_START_NOTIFICATIONS_INTEGRATION.md +275 -0
  42. package/documentation/HEROKU_DEPLOYMENT.md +134 -0
  43. package/documentation/HEROKU_SCHEDULER_SETUP.md +271 -0
  44. package/documentation/JACKPOT_API.md +521 -0
  45. package/documentation/JACKPOT_DEPLOYMENT_GUIDE.md +362 -0
  46. package/documentation/JWT_IMPLEMENTATION_SUMMARY.md +373 -0
  47. package/documentation/JWT_QUICK_SETUP.md +268 -0
  48. package/documentation/JWT_TESTING_GUIDE.md +404 -0
  49. package/documentation/KEEPER_RECOVERY_GUIDE.md +381 -0
  50. package/documentation/KEEPER_SETUP.md +206 -0
  51. package/documentation/KEEPER_STATE_MACHINE.md +423 -0
  52. package/documentation/LATEST_PRODUCTION_SETUP.md +387 -0
  53. package/documentation/LOCAL_VOTING_TEST.md +279 -0
  54. package/documentation/ORACLE_FIXES_SUMMARY.md +188 -0
  55. package/documentation/ORACLE_POSTGRESQL_UPDATE.md +202 -0
  56. package/documentation/PAYMENT_DEPLOYMENT.md +209 -0
  57. package/documentation/PNL_TRACKING_SETUP.md +189 -0
  58. package/documentation/PREVENTING_LOCKUP_ERRORS.md +472 -0
  59. package/documentation/PRODUCTION_READY_SUMMARY.md +227 -0
  60. package/documentation/PUBLIC_VS_PRIVATE_ENDPOINTS.md +278 -0
  61. package/documentation/QUICK_AUTH_SETUP.md +99 -0
  62. package/documentation/QUICK_DEPLOY.md +224 -0
  63. package/documentation/QUICK_FIX.md +114 -0
  64. package/documentation/QUICK_START.md +152 -0
  65. package/documentation/REFEREE_MODE_GUIDE.md +392 -0
  66. package/documentation/RETENTION_CORE_ACTION_UPDATE.md +313 -0
  67. package/documentation/RETENTION_UPDATE_SUMMARY.md +108 -0
  68. package/documentation/RUN_MIGRATION_NOW.md +39 -0
  69. package/documentation/SCRIPTS_UPDATE_SUMMARY.md +251 -0
  70. package/documentation/SETUP_GUIDE.md +184 -0
  71. package/documentation/STATE_MACHINE_IMPLEMENTATION.md +250 -0
  72. package/documentation/TELEGRAM_NOTIFICATIONS_DIAGNOSIS.md +361 -0
  73. package/documentation/UNIFIED_ARCHITECTURE.md +231 -0
  74. package/documentation/VOTING_DEPLOYMENT_SUMMARY.md +392 -0
  75. package/documentation/WEBSOCKET_ARCHITECTURE.md +881 -0
  76. package/documentation/WHAT_WE_BUILT_TODAY.md +369 -0
  77. package/documentation/latest/LATEST_PRODUCTION_SETUP.md +865 -0
  78. package/ecosystem.config.js +65 -0
  79. package/env.template +125 -0
  80. package/middleware/apiKeyAuth.js +136 -0
  81. package/middleware/authenticate.js +214 -0
  82. package/middleware/developerUserAuth.js +76 -0
  83. package/middleware/socketAuth.js +69 -0
  84. package/package.json +49 -0
  85. package/postman/Dubs-API-v1-With-Voting.postman_collection.json +555 -0
  86. package/postman/Dubs-API-v1.postman_collection.json +205 -0
  87. package/postman/Dubs_Developer_API.postman_collection.json +662 -0
  88. package/postman/QUICKSTART.md +118 -0
  89. package/postman/QUICK_REFERENCE.md +246 -0
  90. package/postman/README.md +71 -0
  91. package/postman/VOTING_API_GUIDE.md +426 -0
  92. package/refactor/Animations.md +148 -0
  93. package/refactor/Chat.md +252 -0
  94. package/routes/actionsRoutes.js +699 -0
  95. package/routes/adminRoutes.js +370 -0
  96. package/routes/analyticsRoutes.js +1262 -0
  97. package/routes/arcadeRoutes.js +557 -0
  98. package/routes/authRoutes.js +2310 -0
  99. package/routes/avatarRoutes.js +85 -0
  100. package/routes/botRoutes.js +211 -0
  101. package/routes/chatRoutes.js +377 -0
  102. package/routes/cryptoPriceRoutes.js +105 -0
  103. package/routes/developerRoutes.js +4201 -0
  104. package/routes/deviceRoutes.js +214 -0
  105. package/routes/dmRoutes.js +167 -0
  106. package/routes/esportsRoutes.js +806 -0
  107. package/routes/exchangeRateRoutes.js +233 -0
  108. package/routes/gamesRoutes.js +3028 -0
  109. package/routes/jackpotRoutes.js +754 -0
  110. package/routes/keeperMonitoringRoutes.js +156 -0
  111. package/routes/keeperWebhookRoutes.js +466 -0
  112. package/routes/livescoresRoutes.js +31 -0
  113. package/routes/pickemAdminRoutes.js +199 -0
  114. package/routes/pickemRoutes.js +231 -0
  115. package/routes/playerStatsRoutes.js +147 -0
  116. package/routes/portfolioRoutes.js +217 -0
  117. package/routes/promoRoutes.js +418 -0
  118. package/routes/referralEarningsRoutes.js +392 -0
  119. package/routes/socialRoutes.js +459 -0
  120. package/routes/sportsRoutes.js +1271 -0
  121. package/routes/survivorAdminRoutes.js +345 -0
  122. package/routes/survivorRoutes.js +756 -0
  123. package/routes/uploadRoutes.js +256 -0
  124. package/routes/userProfileRoutes.js +244 -0
  125. package/routes/whatsNewRoutes.js +331 -0
  126. package/scripts/.claude/settings.local.json +15 -0
  127. package/scripts/README.md +170 -0
  128. package/scripts/RESTART_EVERYTHING.sh +104 -0
  129. package/scripts/add-claim-columns.sql +48 -0
  130. package/scripts/add-crypto-prices-cache.sql +27 -0
  131. package/scripts/add-exchange-rates-cache.sql +40 -0
  132. package/scripts/add-game-invite-column.sql +23 -0
  133. package/scripts/add-game-invite-notification.sql +33 -0
  134. package/scripts/add-game-invite-telegram-pref.sql +16 -0
  135. package/scripts/add-game-joined-notification.sql +16 -0
  136. package/scripts/add-game-joined-pref.js +40 -0
  137. package/scripts/add-game-joined-preference.sql +6 -0
  138. package/scripts/add-game-start-notifications.sql +41 -0
  139. package/scripts/add-notification-flags-to-games.sql +55 -0
  140. package/scripts/add-pending-game-dismissals.sql +19 -0
  141. package/scripts/add-preferred-currency.sql +34 -0
  142. package/scripts/add-winner-columns.js +61 -0
  143. package/scripts/add_mention_system.sql +53 -0
  144. package/scripts/add_payment_system.sql +96 -0
  145. package/scripts/add_sports_event_id_column.sql +22 -0
  146. package/scripts/analyze-cohort-data-heroku.js +276 -0
  147. package/scripts/analyze-cohort-data.js +295 -0
  148. package/scripts/analyze-prod-cohorts.sh +10 -0
  149. package/scripts/backfill-matchup-images.js +245 -0
  150. package/scripts/backfill-missing-signatures.js +175 -0
  151. package/scripts/backfill-referral-earnings.js +202 -0
  152. package/scripts/check-chat-schema.js +130 -0
  153. package/scripts/check-db.sh +14 -0
  154. package/scripts/check_oracle_in_game.js +54 -0
  155. package/scripts/cleanup-database.js +193 -0
  156. package/scripts/clear-notification-cache.js +85 -0
  157. package/scripts/convert-mnemonic.js +50 -0
  158. package/scripts/create-users-table.sql +44 -0
  159. package/scripts/debug-cohort-counts.js +248 -0
  160. package/scripts/debug-winner-calc.js +84 -0
  161. package/scripts/deploy-payment-system.sh +118 -0
  162. package/scripts/deploy-to-heroku.sh +63 -0
  163. package/scripts/diagnose-locked-round.js +143 -0
  164. package/scripts/dubs-cli.js +720 -0
  165. package/scripts/dump-account.js +65 -0
  166. package/scripts/find-vrf-offset.js +48 -0
  167. package/scripts/fix-chat-notifications-constraint.sql +122 -0
  168. package/scripts/fix-claim-columns.js +124 -0
  169. package/scripts/fix-constraint-now.js +44 -0
  170. package/scripts/fix-lock-timestamps.js +96 -0
  171. package/scripts/fix-locked-round.sh +126 -0
  172. package/scripts/fix-missing-badges.sql +91 -0
  173. package/scripts/fix-payment-notifications.sql +41 -0
  174. package/scripts/force-new-round.js +55 -0
  175. package/scripts/force-resolve-and-claim.js +278 -0
  176. package/scripts/important/README.md +115 -0
  177. package/scripts/important/authority-force-lock.js +197 -0
  178. package/scripts/important/authority-resolve-game.js +267 -0
  179. package/scripts/important/check-game-status.js +373 -0
  180. package/scripts/important/list-pending-games-by-version.js +270 -0
  181. package/scripts/important/reconcile-v1-v2-payouts.js +270 -0
  182. package/scripts/initialize-jackpot.js +111 -0
  183. package/scripts/jackpot/.claude/settings.local.json +10 -0
  184. package/scripts/jackpot/force-reset.js +84 -0
  185. package/scripts/jackpot/initialize-mainnet.js +100 -0
  186. package/scripts/jackpot/keeper.js +742 -0
  187. package/scripts/jackpot/status.js +107 -0
  188. package/scripts/jackpot/update-round-duration.js +143 -0
  189. package/scripts/keeper-bot.js +112 -0
  190. package/scripts/list-pending-games.js +131 -0
  191. package/scripts/migrate-chat-v2.js +127 -0
  192. package/scripts/migrate-chat-winners.js +84 -0
  193. package/scripts/migrate-chat.sh +17 -0
  194. package/scripts/migrate-game-invite.js +83 -0
  195. package/scripts/migrate-heroku-game-notifications.sh +159 -0
  196. package/scripts/migrations/001_analytics_tables.sql +422 -0
  197. package/scripts/migrations/002_add_matchup_image_url.sql +14 -0
  198. package/scripts/migrations/003_referral_earnings.sql +208 -0
  199. package/scripts/migrations/004_add_whats_new_notification_type.sql +62 -0
  200. package/scripts/migrations/005_add_connect4_your_turn_notification.sql +61 -0
  201. package/scripts/migrations/005_push_notifications.sql +55 -0
  202. package/scripts/migrations/006_add_draw_team_players.sql +28 -0
  203. package/scripts/migrations/006_add_game_cancelled_notification.sql +62 -0
  204. package/scripts/migrations/007_add_gif_url.sql +8 -0
  205. package/scripts/migrations/008_add_connect4_columns.sql +139 -0
  206. package/scripts/migrations/008_add_pool_tracking.sql +22 -0
  207. package/scripts/migrations/009_create_survivor_pool_tables.sql +174 -0
  208. package/scripts/migrations/010_add_survivor_pool_outcome.sql +28 -0
  209. package/scripts/migrations/011_create_developer_tables.sql +67 -0
  210. package/scripts/migrations/011_fix_keeper_tables.sql +85 -0
  211. package/scripts/migrations/012_create_developer_webhooks.sql +31 -0
  212. package/scripts/migrations/013_add_network_mode.sql +18 -0
  213. package/scripts/migrations/014_create_developer_app_users.sql +19 -0
  214. package/scripts/migrations/015_add_ui_config.sql +4 -0
  215. package/scripts/migrations/016_add_resolution_secret.sql +4 -0
  216. package/scripts/migrations/017_add_external_game_id.sql +3 -0
  217. package/scripts/migrations/018_create_pickem_tables.sql +115 -0
  218. package/scripts/migrations/019_expo_push_tokens.sql +19 -0
  219. package/scripts/migrations/create_whats_new_tables.sql +88 -0
  220. package/scripts/migrations/drop_live_games_tables.sql +34 -0
  221. package/scripts/open-jackpot-round.js +85 -0
  222. package/scripts/purge-all-data.sh +329 -0
  223. package/scripts/purge-all-data.sql +142 -0
  224. package/scripts/purge-heroku-data.sh +149 -0
  225. package/scripts/purge-heroku-data.sql +62 -0
  226. package/scripts/rebuild-heroku-database.sh +113 -0
  227. package/scripts/recover-funds.js +357 -0
  228. package/scripts/regenerate-epl-images.js +278 -0
  229. package/scripts/resize-s3-matchup-images.js +374 -0
  230. package/scripts/resolve-direct.js +88 -0
  231. package/scripts/resolve-mock-game.js +124 -0
  232. package/scripts/resolve-pickem-game.js +55 -0
  233. package/scripts/resolve-round-manual.js +83 -0
  234. package/scripts/resolve-stuck-game.js +382 -0
  235. package/scripts/resolve-stuck-round.js +42 -0
  236. package/scripts/run-connect4-migration.sh +16 -0
  237. package/scripts/run-mention-migration.sh +32 -0
  238. package/scripts/run-payment-migration.sh +51 -0
  239. package/scripts/run-preferred-currency-migration.sh +31 -0
  240. package/scripts/run-referral-earnings-migration.sh +32 -0
  241. package/scripts/run-survivor-outcome-migration.sh +16 -0
  242. package/scripts/seed-test-users.js +346 -0
  243. package/scripts/setup-auth-tables.js +78 -0
  244. package/scripts/setup-complete-database.sql +992 -0
  245. package/scripts/setup-database-fresh.sh +359 -0
  246. package/scripts/setup-heroku-keeper.sh +48 -0
  247. package/scripts/setup-keeper-database.js +83 -0
  248. package/scripts/setup-keeper-state-db.sql +110 -0
  249. package/scripts/setup-oracle.sh +39 -0
  250. package/scripts/setup-pnl-tracking.js +111 -0
  251. package/scripts/start-devnet.sh +14 -0
  252. package/scripts/test-arcade-devnet.sh +160 -0
  253. package/scripts/test-arcade-match.sh +109 -0
  254. package/scripts/test-automatic-mode.sh +239 -0
  255. package/scripts/test-connect4-cancel-claim.js +370 -0
  256. package/scripts/test-connect4-e2e.js +369 -0
  257. package/scripts/test-connect4-resolve.js +369 -0
  258. package/scripts/test-game-state-endpoint.js +136 -0
  259. package/scripts/test-invite-notification.js +86 -0
  260. package/scripts/test-jackpot-api.sh +71 -0
  261. package/scripts/test-poll-confirmation.js +267 -0
  262. package/scripts/test-resolve-game.js +271 -0
  263. package/scripts/test-resolve-signature.js +223 -0
  264. package/scripts/test-signature-preservation.js +124 -0
  265. package/scripts/test-state-machine.js +291 -0
  266. package/scripts/test-webhook-receiver.js +60 -0
  267. package/scripts/update-notification-constraint.js +52 -0
  268. package/scripts/verify-account-layout.js +145 -0
  269. package/scripts/verify-winner-algorithm.js +278 -0
  270. package/server.js +5259 -0
  271. package/services/arcadeMatchService.js +763 -0
  272. package/services/automaticGameOracle.js +1596 -0
  273. package/services/chatService.js +1612 -0
  274. package/services/connect4GameService.js +1049 -0
  275. package/services/connect4NotificationService.js +374 -0
  276. package/services/cryptoPriceService.js +223 -0
  277. package/services/customGameResolver.js +260 -0
  278. package/services/db.js +79 -0
  279. package/services/directMessageService.js +389 -0
  280. package/services/discordNotifications.js +160 -0
  281. package/services/exchangeRateService.js +289 -0
  282. package/services/expoPushService.js +314 -0
  283. package/services/gamesCacheService.js +539 -0
  284. package/services/jackpotHistory.js +331 -0
  285. package/services/jackpotService.js +856 -0
  286. package/services/keeperStateService.js +355 -0
  287. package/services/matchupImageService.js +591 -0
  288. package/services/notificationCacheService.js +407 -0
  289. package/services/pickemOracle.js +440 -0
  290. package/services/playerStatsService.js +389 -0
  291. package/services/portfolioService.js +555 -0
  292. package/services/promoService.js +757 -0
  293. package/services/promoTreasuryService.js +239 -0
  294. package/services/pushNotifications.js +353 -0
  295. package/services/redisService.js +422 -0
  296. package/services/referralEarningsService.js +728 -0
  297. package/services/s3Service.js +396 -0
  298. package/services/socialService.js +1202 -0
  299. package/services/survivorOracle.js +469 -0
  300. package/services/survivorSimulator.js +475 -0
  301. package/services/telegramNotifications.js +461 -0
  302. package/services/userProfileStatsService.js +1185 -0
  303. package/services/whatsNewService.js +388 -0
  304. package/utils/urlHelper.js +95 -0
@@ -0,0 +1,289 @@
1
+ const axios = require('axios');
2
+ const { pool } = require('./db'); // Shared database pool
3
+
4
+ class ExchangeRateService {
5
+ constructor() {
6
+ this.apiKey = process.env.EXCHANGE_API_KEY || '757419e9be20039acaf308a9';
7
+ this.apiBaseUrl = process.env.EXCHANGE_API_BASE_URL || 'https://v6.exchangerate-api.com/v6';
8
+ this.cacheTTL = parseInt(process.env.EXCHANGE_CACHE_TTL) || 300; // 5 minutes in seconds
9
+ this.baseCurrency = process.env.BASE_CURRENCY || 'USD';
10
+ this.supportedCurrencies = (process.env.SUPPORTED_CURRENCIES || 'USD,EUR,CAD,GBP,JPY,AUD,CHF,CNY,SEK,NZD').split(',');
11
+
12
+ // Initialize cache table on startup
13
+ this.initializeCacheTable();
14
+ }
15
+
16
+ /**
17
+ * Initialize the exchange_rates_cache table if it doesn't exist
18
+ */
19
+ async initializeCacheTable() {
20
+ try {
21
+ await pool.query(`
22
+ CREATE TABLE IF NOT EXISTS exchange_rates_cache (
23
+ id SERIAL PRIMARY KEY,
24
+ base_currency VARCHAR(3) NOT NULL UNIQUE,
25
+ rates JSONB NOT NULL,
26
+ last_updated TIMESTAMP NOT NULL DEFAULT NOW(),
27
+ expires_at TIMESTAMP NOT NULL,
28
+ created_at TIMESTAMP DEFAULT NOW()
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_exchange_rates_base ON exchange_rates_cache(base_currency);
32
+ CREATE INDEX IF NOT EXISTS idx_exchange_rates_expires ON exchange_rates_cache(expires_at);
33
+ `);
34
+ console.log('[Exchange Rates] Cache table initialized');
35
+ } catch (error) {
36
+ console.error('[Exchange Rates] Error initializing cache table:', error);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Get exchange rates for all supported currencies
42
+ * @param {string} base - Base currency (default: USD)
43
+ * @returns {Object} Exchange rates object
44
+ */
45
+ async getExchangeRates(base = this.baseCurrency) {
46
+ try {
47
+ // Try to get from cache first
48
+ const cachedRates = await this.getCachedRates(base);
49
+ if (cachedRates) {
50
+ console.log(`[Exchange Rates] Retrieved ${base} from cache`);
51
+ return {
52
+ ...cachedRates,
53
+ source: 'cache'
54
+ };
55
+ }
56
+
57
+ // If not in cache, fetch from API
58
+ console.log(`[Exchange Rates] Fetching fresh rates for ${base} from API`);
59
+ const freshRates = await this.fetchFromAPI(base);
60
+
61
+ // Cache the fresh rates
62
+ await this.cacheRates(base, freshRates);
63
+
64
+ return {
65
+ ...freshRates,
66
+ source: 'api'
67
+ };
68
+ } catch (error) {
69
+ console.error('[Exchange Rates] Error getting exchange rates:', error);
70
+ throw new Error('Failed to retrieve exchange rates');
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Get specific currency pair rate
76
+ * @param {string} from - From currency
77
+ * @param {string} to - To currency
78
+ * @returns {Object} Exchange rate data
79
+ */
80
+ async getCurrencyPair(from, to) {
81
+ if (!this.supportedCurrencies.includes(from) || !this.supportedCurrencies.includes(to)) {
82
+ throw new Error('Unsupported currency pair');
83
+ }
84
+
85
+ const rates = await this.getExchangeRates(from);
86
+
87
+ if (!rates.rates[to]) {
88
+ throw new Error(`Exchange rate for ${from} to ${to} not available`);
89
+ }
90
+
91
+ return {
92
+ from,
93
+ to,
94
+ rate: rates.rates[to],
95
+ timestamp: rates.timestamp,
96
+ date: rates.date,
97
+ source: rates.source
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Convert amount from one currency to another
103
+ * @param {number} amount - Amount to convert
104
+ * @param {string} from - From currency
105
+ * @param {string} to - To currency
106
+ * @returns {Object} Conversion result
107
+ */
108
+ async convertCurrency(amount, from, to) {
109
+ const pairData = await this.getCurrencyPair(from, to);
110
+ const convertedAmount = amount * pairData.rate;
111
+
112
+ return {
113
+ originalAmount: amount,
114
+ convertedAmount: parseFloat(convertedAmount.toFixed(4)),
115
+ from,
116
+ to,
117
+ rate: pairData.rate,
118
+ timestamp: pairData.timestamp,
119
+ source: pairData.source
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Get cached exchange rates from PostgreSQL
125
+ * @param {string} base - Base currency
126
+ * @returns {Object|null} Cached rates or null
127
+ */
128
+ async getCachedRates(base) {
129
+ try {
130
+ const result = await pool.query(
131
+ 'SELECT rates, last_updated FROM exchange_rates_cache WHERE base_currency = $1 AND expires_at > NOW()',
132
+ [base]
133
+ );
134
+
135
+ if (result.rows.length > 0) {
136
+ const row = result.rows[0];
137
+ return {
138
+ ...row.rates,
139
+ lastUpdated: row.last_updated
140
+ };
141
+ }
142
+
143
+ return null;
144
+ } catch (error) {
145
+ console.error('[Exchange Rates] Error getting cached rates:', error);
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Cache exchange rates in PostgreSQL
152
+ * @param {string} base - Base currency
153
+ * @param {Object} rates - Exchange rates data
154
+ */
155
+ async cacheRates(base, rates) {
156
+ try {
157
+ const expiresAt = new Date(Date.now() + this.cacheTTL * 1000);
158
+
159
+ await pool.query(`
160
+ INSERT INTO exchange_rates_cache (base_currency, rates, last_updated, expires_at)
161
+ VALUES ($1, $2, NOW(), $3)
162
+ ON CONFLICT (base_currency)
163
+ DO UPDATE SET
164
+ rates = $2,
165
+ last_updated = NOW(),
166
+ expires_at = $3
167
+ `, [base, JSON.stringify(rates), expiresAt]);
168
+
169
+ console.log(`[Exchange Rates] Cached ${base} rates with TTL: ${this.cacheTTL}s`);
170
+ } catch (error) {
171
+ console.error('[Exchange Rates] Error caching rates:', error);
172
+ // Don't throw error, caching failure shouldn't break the service
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Fetch exchange rates from external API
178
+ * @param {string} base - Base currency
179
+ * @returns {Object} Fresh exchange rates
180
+ */
181
+ async fetchFromAPI(base) {
182
+ try {
183
+ const url = `${this.apiBaseUrl}/${this.apiKey}/latest/${base}`;
184
+ const response = await axios.get(url, {
185
+ timeout: 10000, // 10 second timeout
186
+ headers: {
187
+ 'User-Agent': 'Dubs-Exchange-API/1.0'
188
+ }
189
+ });
190
+
191
+ if (!response.data || !response.data.conversion_rates) {
192
+ throw new Error('Invalid response from exchange rate API');
193
+ }
194
+
195
+ // Check if the request was successful
196
+ if (response.data.result !== 'success') {
197
+ throw new Error(`API Error: ${response.data['error-type'] || 'Unknown error'}`);
198
+ }
199
+
200
+ // Filter rates to only include supported currencies
201
+ const filteredRates = {};
202
+ for (const currency of this.supportedCurrencies) {
203
+ if (response.data.conversion_rates[currency] !== undefined) {
204
+ filteredRates[currency] = response.data.conversion_rates[currency];
205
+ }
206
+ }
207
+
208
+ return {
209
+ base: response.data.base_code,
210
+ date: new Date(response.data.time_last_update_unix * 1000).toISOString().split('T')[0],
211
+ timestamp: Date.now(),
212
+ rates: filteredRates,
213
+ lastUpdated: response.data.time_last_update_utc,
214
+ nextUpdate: response.data.time_next_update_utc
215
+ };
216
+ } catch (error) {
217
+ if (error.response) {
218
+ console.error('[Exchange Rates] API Error:', error.response.status, error.response.data);
219
+
220
+ // Handle specific API errors
221
+ if (error.response.status === 403) {
222
+ throw new Error('Exchange rate API: Invalid or expired API key');
223
+ } else if (error.response.status === 404) {
224
+ throw new Error('Exchange rate API: Unsupported currency code');
225
+ } else if (error.response.status === 429) {
226
+ throw new Error('Exchange rate API: Rate limit exceeded');
227
+ }
228
+
229
+ throw new Error(`Exchange rate API error: ${error.response.status}`);
230
+ } else if (error.request) {
231
+ console.error('[Exchange Rates] Network Error:', error.message);
232
+ throw new Error('Network error connecting to exchange rate API');
233
+ } else {
234
+ console.error('[Exchange Rates] Error:', error.message);
235
+ throw new Error('Failed to fetch exchange rates');
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Get list of supported currencies
242
+ * @returns {Array} List of supported currency codes
243
+ */
244
+ getSupportedCurrencies() {
245
+ return this.supportedCurrencies;
246
+ }
247
+
248
+ /**
249
+ * Clear cache for a specific base currency
250
+ * @param {string} base - Base currency
251
+ */
252
+ async clearCache(base = this.baseCurrency) {
253
+ try {
254
+ await pool.query(
255
+ 'DELETE FROM exchange_rates_cache WHERE base_currency = $1',
256
+ [base]
257
+ );
258
+ console.log(`[Exchange Rates] Cache cleared for ${base}`);
259
+ } catch (error) {
260
+ console.error('[Exchange Rates] Error clearing cache:', error);
261
+ throw new Error('Failed to clear cache');
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Clean up expired cache entries
267
+ */
268
+ async cleanupExpiredCache() {
269
+ try {
270
+ const result = await pool.query(
271
+ 'DELETE FROM exchange_rates_cache WHERE expires_at < NOW()'
272
+ );
273
+ if (result.rowCount > 0) {
274
+ console.log(`[Exchange Rates] Cleaned up ${result.rowCount} expired cache entries`);
275
+ }
276
+ } catch (error) {
277
+ console.error('[Exchange Rates] Error cleaning up cache:', error);
278
+ }
279
+ }
280
+ }
281
+
282
+ module.exports = new ExchangeRateService();
283
+
284
+
285
+
286
+
287
+
288
+
289
+
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Expo Push Notification Service
3
+ * Handles token registration, push sending, and pick'em-specific notifications.
4
+ */
5
+
6
+ const { Expo } = require('expo-server-sdk');
7
+ const { pool } = require('./db');
8
+
9
+ const expo = new Expo();
10
+
11
+ // ── Token Management ──
12
+
13
+ /**
14
+ * Register or reactivate an Expo push token for a user.
15
+ */
16
+ async function registerToken(userId, token, platform, deviceName, developerAppId) {
17
+ if (!Expo.isExpoPushToken(token)) {
18
+ throw new Error(`Invalid Expo push token: ${token}`);
19
+ }
20
+
21
+ const result = await pool.query(
22
+ `INSERT INTO expo_push_tokens (user_id, token, platform, device_name, developer_app_id, active, updated_at)
23
+ VALUES ($1, $2, $3, $4, $5, true, NOW())
24
+ ON CONFLICT (user_id, token)
25
+ DO UPDATE SET active = true, platform = $3, device_name = $4, developer_app_id = $5, updated_at = NOW()
26
+ RETURNING *`,
27
+ [userId, token, platform, deviceName || null, developerAppId || null]
28
+ );
29
+
30
+ console.log(`[ExpoPush] Token registered: ${token.slice(0, 30)}... for user ${userId}`);
31
+ return result.rows[0];
32
+ }
33
+
34
+ /**
35
+ * Deactivate a push token for a user.
36
+ */
37
+ async function unregisterToken(userId, token) {
38
+ await pool.query(
39
+ `UPDATE expo_push_tokens SET active = false, updated_at = NOW()
40
+ WHERE user_id = $1 AND token = $2`,
41
+ [userId, token]
42
+ );
43
+ console.log(`[ExpoPush] Token unregistered for user ${userId}`);
44
+ }
45
+
46
+ /**
47
+ * Get all active tokens for a user, optionally filtered by app.
48
+ */
49
+ async function getTokensForUser(userId, developerAppId) {
50
+ const params = [userId];
51
+ let where = 'WHERE user_id = $1 AND active = true';
52
+ if (developerAppId) {
53
+ where += ' AND developer_app_id = $2';
54
+ params.push(developerAppId);
55
+ }
56
+ const result = await pool.query(
57
+ `SELECT token, platform, device_name, created_at, updated_at FROM expo_push_tokens ${where} ORDER BY updated_at DESC`,
58
+ params
59
+ );
60
+ return result.rows;
61
+ }
62
+
63
+ /**
64
+ * Get all active tokens for an app with user info (for dashboard).
65
+ */
66
+ async function getTokensForApp(developerAppId) {
67
+ const result = await pool.query(
68
+ `SELECT ept.token, ept.platform, ept.device_name, ept.active, ept.created_at,
69
+ u.wallet_address, u.username, u.avatar
70
+ FROM expo_push_tokens ept
71
+ JOIN users u ON ept.user_id = u.id
72
+ WHERE ept.developer_app_id = $1 AND ept.active = true
73
+ ORDER BY ept.updated_at DESC`,
74
+ [developerAppId]
75
+ );
76
+ return result.rows;
77
+ }
78
+
79
+ // ── Push Sending ──
80
+
81
+ /**
82
+ * Send a push notification to all active tokens for given user IDs.
83
+ * Auto-deactivates tokens that return DeviceNotRegistered.
84
+ */
85
+ async function sendToUsers(userIds, notification) {
86
+ if (!userIds || userIds.length === 0) return { sent: 0, failed: 0 };
87
+
88
+ const result = await pool.query(
89
+ `SELECT id, user_id, token FROM expo_push_tokens
90
+ WHERE user_id = ANY($1) AND active = true`,
91
+ [userIds]
92
+ );
93
+
94
+ if (result.rows.length === 0) return { sent: 0, failed: 0 };
95
+
96
+ const messages = result.rows
97
+ .filter(row => Expo.isExpoPushToken(row.token))
98
+ .map(row => ({
99
+ to: row.token,
100
+ sound: 'default',
101
+ title: notification.title,
102
+ body: notification.body,
103
+ data: notification.data || {},
104
+ channelId: notification.channelId || 'default',
105
+ }));
106
+
107
+ if (messages.length === 0) return { sent: 0, failed: 0 };
108
+
109
+ const chunks = expo.chunkPushNotifications(messages);
110
+ let sent = 0;
111
+ let failed = 0;
112
+ const tokensToDeactivate = [];
113
+
114
+ for (const chunk of chunks) {
115
+ try {
116
+ const receipts = await expo.sendPushNotificationsAsync(chunk);
117
+ for (let i = 0; i < receipts.length; i++) {
118
+ const receipt = receipts[i];
119
+ if (receipt.status === 'ok') {
120
+ sent++;
121
+ } else {
122
+ failed++;
123
+ if (receipt.details?.error === 'DeviceNotRegistered') {
124
+ tokensToDeactivate.push(chunk[i].to);
125
+ }
126
+ console.error(`[ExpoPush] Error sending to ${chunk[i].to}: ${receipt.message || receipt.details?.error}`);
127
+ }
128
+ }
129
+ } catch (err) {
130
+ console.error('[ExpoPush] Chunk send error:', err.message);
131
+ failed += chunk.length;
132
+ }
133
+ }
134
+
135
+ // Deactivate stale tokens
136
+ if (tokensToDeactivate.length > 0) {
137
+ await pool.query(
138
+ `UPDATE expo_push_tokens SET active = false, updated_at = NOW()
139
+ WHERE token = ANY($1)`,
140
+ [tokensToDeactivate]
141
+ ).catch(err => console.error('[ExpoPush] Failed to deactivate stale tokens:', err.message));
142
+ console.log(`[ExpoPush] Deactivated ${tokensToDeactivate.length} stale token(s)`);
143
+ }
144
+
145
+ console.log(`[ExpoPush] Sent: ${sent}, Failed: ${failed}`);
146
+ return { sent, failed };
147
+ }
148
+
149
+ /**
150
+ * Send a push notification to a single user.
151
+ */
152
+ async function sendToUser(userId, notification) {
153
+ return sendToUsers([userId], notification);
154
+ }
155
+
156
+ // ── Pick'em Notification Helpers ──
157
+
158
+ /**
159
+ * Notify users who picked on a fight that just went live.
160
+ */
161
+ async function sendFightLiveNotifications(fightId, fight) {
162
+ // Get all users who have picks on this fight
163
+ const pickResult = await pool.query(
164
+ `SELECT DISTINCT pe.user_id
165
+ FROM pickem_picks pp
166
+ JOIN pickem_entries pe ON pp.entry_id = pe.id
167
+ WHERE pp.fight_id = $1`,
168
+ [fightId]
169
+ );
170
+
171
+ if (pickResult.rows.length === 0) return;
172
+
173
+ const userIds = pickResult.rows.map(r => r.user_id);
174
+ const fighterA = fight.fighterAName || fight.fighter_a_name || 'Fighter A';
175
+ const fighterB = fight.fighterBName || fight.fighter_b_name || 'Fighter B';
176
+
177
+ await sendToUsers(userIds, {
178
+ title: 'Fight Starting NOW!',
179
+ body: `${fighterA} vs ${fighterB} is LIVE!`,
180
+ data: {
181
+ type: 'fight_live',
182
+ fightId: String(fightId),
183
+ poolId: String(fight.poolId || fight.pool_id),
184
+ },
185
+ channelId: 'pickem',
186
+ });
187
+
188
+ console.log(`[ExpoPush] Fight live notifications sent for fight ${fightId} to ${userIds.length} user(s)`);
189
+ }
190
+
191
+ /**
192
+ * Notify users who picked on a fight that just resolved.
193
+ * Sends different messages for correct vs incorrect picks.
194
+ */
195
+ async function sendFightResolvedNotifications(fightId, fight) {
196
+ const winner = fight.winner;
197
+ if (!winner) return;
198
+
199
+ // Get all picks for this fight with user IDs
200
+ const pickResult = await pool.query(
201
+ `SELECT pp.pick, pe.user_id
202
+ FROM pickem_picks pp
203
+ JOIN pickem_entries pe ON pp.entry_id = pe.id
204
+ WHERE pp.fight_id = $1`,
205
+ [fightId]
206
+ );
207
+
208
+ if (pickResult.rows.length === 0) return;
209
+
210
+ const fighterA = fight.fighterAName || fight.fighter_a_name || 'Fighter A';
211
+ const fighterB = fight.fighterBName || fight.fighter_b_name || 'Fighter B';
212
+ const winnerName = winner === 'a' ? fighterA : fighterB;
213
+ const method = fight.method || '';
214
+ const methodStr = method ? ` by ${method}` : '';
215
+ const poolId = String(fight.poolId || fight.pool_id);
216
+
217
+ const correctUserIds = [];
218
+ const incorrectUserIds = [];
219
+
220
+ for (const row of pickResult.rows) {
221
+ if (row.pick === winner) {
222
+ correctUserIds.push(row.user_id);
223
+ } else {
224
+ incorrectUserIds.push(row.user_id);
225
+ }
226
+ }
227
+
228
+ const promises = [];
229
+
230
+ if (correctUserIds.length > 0) {
231
+ promises.push(
232
+ sendToUsers(correctUserIds, {
233
+ title: 'Correct Pick!',
234
+ body: `${winnerName} wins${methodStr}! Your pick was correct!`,
235
+ data: { type: 'fight_resolved', fightId: String(fightId), poolId, result: 'correct' },
236
+ channelId: 'pickem',
237
+ })
238
+ );
239
+ }
240
+
241
+ if (incorrectUserIds.length > 0) {
242
+ promises.push(
243
+ sendToUsers(incorrectUserIds, {
244
+ title: 'Fight Result',
245
+ body: `${winnerName} wins${methodStr}. Better luck on the next fight!`,
246
+ data: { type: 'fight_resolved', fightId: String(fightId), poolId, result: 'incorrect' },
247
+ channelId: 'pickem',
248
+ })
249
+ );
250
+ }
251
+
252
+ await Promise.all(promises);
253
+ console.log(`[ExpoPush] Fight resolved notifications: ${correctUserIds.length} correct, ${incorrectUserIds.length} incorrect`);
254
+ }
255
+
256
+ /**
257
+ * Notify all users in a pool when it resolves with final rankings.
258
+ */
259
+ async function sendPoolResolvedNotifications(poolId, scores) {
260
+ if (!scores || !scores.entries || scores.entries.length === 0) return;
261
+
262
+ const totalFights = scores.maxScore != null ? undefined : undefined; // scores contain entries with score/rank
263
+
264
+ // Get the total number of resolved fights for context
265
+ const fightCountResult = await pool.query(
266
+ `SELECT COUNT(*) as total FROM pickem_fights WHERE pool_id = $1 AND status = 'final'`,
267
+ [poolId]
268
+ );
269
+ const totalResolved = parseInt(fightCountResult.rows[0]?.total || '0');
270
+
271
+ const promises = [];
272
+
273
+ for (const entry of scores.entries) {
274
+ const rank = entry.rank;
275
+ const score = entry.score || 0;
276
+ const userId = entry.user_id || entry.userId;
277
+ if (!userId) continue;
278
+
279
+ const isWinner = rank === 1;
280
+ const title = isWinner ? "You Won the Pool!" : 'Pool Results';
281
+ const body = isWinner
282
+ ? `Congratulations! You finished #1 with ${score}/${totalResolved} correct picks!`
283
+ : `Final results: You finished #${rank} with ${score}/${totalResolved} correct picks.`;
284
+
285
+ promises.push(
286
+ sendToUser(userId, {
287
+ title,
288
+ body,
289
+ data: {
290
+ type: 'pool_resolved',
291
+ poolId: String(poolId),
292
+ rank: String(rank),
293
+ score: String(score),
294
+ },
295
+ channelId: 'pickem',
296
+ })
297
+ );
298
+ }
299
+
300
+ await Promise.all(promises);
301
+ console.log(`[ExpoPush] Pool resolved notifications sent for pool ${poolId} to ${scores.entries.length} user(s)`);
302
+ }
303
+
304
+ module.exports = {
305
+ registerToken,
306
+ unregisterToken,
307
+ getTokensForUser,
308
+ getTokensForApp,
309
+ sendToUsers,
310
+ sendToUser,
311
+ sendFightLiveNotifications,
312
+ sendFightResolvedNotifications,
313
+ sendPoolResolvedNotifications,
314
+ };