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,757 @@
1
+ /**
2
+ * 🎟️ Promo Code Service
3
+ *
4
+ * Handles promo code management for the Risk-Free First Bet incentive system.
5
+ *
6
+ * How it works:
7
+ * - Promo codes are generated in batches (e.g., 2 daily for Twitter giveaways)
8
+ * - Each code is worth a fixed amount (default: 0.1 SOL)
9
+ * - User enters code during onboarding → code is reserved for them
10
+ * - When user joins a game, treasury wallet pays on their behalf
11
+ * - If they win → real SOL winnings
12
+ * - If they lose → winner takes pot (treasury's money)
13
+ * - If tie/refund → treasury gets auto-refund (not user)
14
+ */
15
+
16
+ const { pool } = require('./db');
17
+
18
+ // Configuration
19
+ const DEFAULT_PROMO_AMOUNT_LAMPORTS = 100_000_000; // 0.1 SOL
20
+ const LAMPORTS_PER_SOL = 1_000_000_000;
21
+ const CODE_RESERVATION_TIMEOUT_MINUTES = 30;
22
+
23
+ /**
24
+ * Generate a unique promo code
25
+ * Format: DUBS-XXXX-XXXX (easy to type, share on Twitter)
26
+ */
27
+ function generatePromoCode() {
28
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Excludes confusing chars (0,O,1,I)
29
+ const segment = () => Array(4).fill(0).map(() => chars[Math.floor(Math.random() * chars.length)]).join('');
30
+ return `DUBS-${segment()}-${segment()}`;
31
+ }
32
+
33
+ /**
34
+ * Create promo codes table if it doesn't exist
35
+ * Call this once during server startup
36
+ */
37
+ async function ensureTablesExist() {
38
+ try {
39
+ await pool.query(`
40
+ CREATE TABLE IF NOT EXISTS promo_codes (
41
+ id SERIAL PRIMARY KEY,
42
+ code VARCHAR(50) UNIQUE NOT NULL,
43
+ amount_lamports BIGINT NOT NULL DEFAULT ${DEFAULT_PROMO_AMOUNT_LAMPORTS},
44
+
45
+ -- Status: available → reserved → used OR expired
46
+ status VARCHAR(20) DEFAULT 'available',
47
+
48
+ -- Reservation tracking (when user enters code during onboarding)
49
+ reserved_by VARCHAR(255), -- Wallet address
50
+ reserved_at TIMESTAMP,
51
+ reservation_expires_at TIMESTAMP,
52
+
53
+ -- Usage tracking (when user actually joins a game)
54
+ used_by VARCHAR(255), -- Wallet address (should match reserved_by)
55
+ used_at TIMESTAMP,
56
+ used_in_game VARCHAR(255), -- Game ID
57
+
58
+ -- Outcome tracking (after game resolves)
59
+ game_outcome VARCHAR(20), -- won, lost, refunded
60
+ outcome_recorded_at TIMESTAMP,
61
+
62
+ -- Metadata
63
+ created_at TIMESTAMP DEFAULT NOW(),
64
+ expires_at TIMESTAMP, -- Hard expiration (e.g., end of promo campaign)
65
+ batch_id VARCHAR(100), -- For tracking Twitter giveaway batches
66
+ notes TEXT
67
+ );
68
+
69
+ -- Indexes for common queries
70
+ CREATE INDEX IF NOT EXISTS idx_promo_codes_code ON promo_codes(code);
71
+ CREATE INDEX IF NOT EXISTS idx_promo_codes_status ON promo_codes(status);
72
+ CREATE INDEX IF NOT EXISTS idx_promo_codes_reserved_by ON promo_codes(reserved_by);
73
+ CREATE INDEX IF NOT EXISTS idx_promo_codes_used_by ON promo_codes(used_by);
74
+ CREATE INDEX IF NOT EXISTS idx_promo_codes_batch_id ON promo_codes(batch_id);
75
+
76
+ -- Add banner_dismissed column if it doesn't exist (for migration)
77
+ ALTER TABLE promo_codes ADD COLUMN IF NOT EXISTS banner_dismissed BOOLEAN DEFAULT FALSE;
78
+ `);
79
+
80
+ console.log('[PromoService] ✅ promo_codes table ready');
81
+ } catch (error) {
82
+ console.error('[PromoService] ❌ Error creating tables:', error.message);
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Generate a batch of promo codes
89
+ * @param {number} count - Number of codes to generate
90
+ * @param {Object} options - Generation options
91
+ * @returns {Array} - Generated codes
92
+ */
93
+ async function generateBatch(count, options = {}) {
94
+ const {
95
+ amountLamports = DEFAULT_PROMO_AMOUNT_LAMPORTS,
96
+ expiresAt = null,
97
+ batchId = `batch-${Date.now()}`,
98
+ notes = null
99
+ } = options;
100
+
101
+ console.log(`[PromoService] 🎟️ Generating ${count} promo codes (batch: ${batchId})`);
102
+ console.log(`[PromoService] Amount per code: ${amountLamports / LAMPORTS_PER_SOL} SOL`);
103
+
104
+ const codes = [];
105
+ const maxAttempts = count * 3; // Prevent infinite loop on collision
106
+ let attempts = 0;
107
+
108
+ while (codes.length < count && attempts < maxAttempts) {
109
+ attempts++;
110
+ const code = generatePromoCode();
111
+
112
+ try {
113
+ const result = await pool.query(`
114
+ INSERT INTO promo_codes (code, amount_lamports, expires_at, batch_id, notes)
115
+ VALUES ($1, $2, $3, $4, $5)
116
+ ON CONFLICT (code) DO NOTHING
117
+ RETURNING id, code, amount_lamports
118
+ `, [code, amountLamports, expiresAt, batchId, notes]);
119
+
120
+ if (result.rows.length > 0) {
121
+ codes.push({
122
+ id: result.rows[0].id,
123
+ code: result.rows[0].code,
124
+ amountLamports: parseInt(result.rows[0].amount_lamports),
125
+ amountSOL: parseInt(result.rows[0].amount_lamports) / LAMPORTS_PER_SOL
126
+ });
127
+ }
128
+ } catch (error) {
129
+ console.error(`[PromoService] Error inserting code:`, error.message);
130
+ }
131
+ }
132
+
133
+ console.log(`[PromoService] ✅ Generated ${codes.length} codes in ${attempts} attempts`);
134
+ return codes;
135
+ }
136
+
137
+ /**
138
+ * Validate a promo code (check if it exists and is available)
139
+ * Does NOT reserve it - just checks validity
140
+ * @param {string} code - The promo code to validate
141
+ * @returns {Object} - Validation result
142
+ */
143
+ async function validateCode(code) {
144
+ try {
145
+ const normalizedCode = code.trim().toUpperCase();
146
+
147
+ const result = await pool.query(`
148
+ SELECT
149
+ id, code, amount_lamports, status,
150
+ reserved_by, reserved_at, reservation_expires_at,
151
+ expires_at
152
+ FROM promo_codes
153
+ WHERE code = $1
154
+ `, [normalizedCode]);
155
+
156
+ if (result.rows.length === 0) {
157
+ return { valid: false, error: 'Invalid promo code' };
158
+ }
159
+
160
+ const promo = result.rows[0];
161
+
162
+ // Check if code has hard expired
163
+ if (promo.expires_at && new Date(promo.expires_at) < new Date()) {
164
+ return { valid: false, error: 'Promo code has expired' };
165
+ }
166
+
167
+ // Check status
168
+ if (promo.status === 'used') {
169
+ return { valid: false, error: 'Promo code has already been used' };
170
+ }
171
+
172
+ if (promo.status === 'expired') {
173
+ return { valid: false, error: 'Promo code has expired' };
174
+ }
175
+
176
+ // If reserved, check if reservation has expired
177
+ if (promo.status === 'reserved') {
178
+ const reservationExpired = promo.reservation_expires_at &&
179
+ new Date(promo.reservation_expires_at) < new Date();
180
+
181
+ if (!reservationExpired) {
182
+ return {
183
+ valid: false,
184
+ error: 'This promo code is already claimed',
185
+ code: 'ALREADY_CLAIMED'
186
+ };
187
+ }
188
+ // Reservation expired, code is available again
189
+ }
190
+
191
+ return {
192
+ valid: true,
193
+ code: promo.code,
194
+ amountLamports: parseInt(promo.amount_lamports),
195
+ amountSOL: parseInt(promo.amount_lamports) / LAMPORTS_PER_SOL
196
+ };
197
+
198
+ } catch (error) {
199
+ console.error('[PromoService] Error validating code:', error.message);
200
+ return { valid: false, error: 'Error validating promo code' };
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Reserve a promo code for a user during onboarding
206
+ * Reserves for CODE_RESERVATION_TIMEOUT_MINUTES before expiring
207
+ * @param {string} code - The promo code
208
+ * @param {string} walletAddress - User's wallet address
209
+ * @returns {Object} - Reservation result
210
+ */
211
+ async function reserveCode(code, walletAddress) {
212
+ try {
213
+ const normalizedCode = code.trim().toUpperCase();
214
+
215
+ console.log(`[PromoService] 🎯 Reserving code ${normalizedCode} for ${walletAddress.slice(0, 8)}...`);
216
+
217
+ // First validate
218
+ const validation = await validateCode(normalizedCode);
219
+ if (!validation.valid) {
220
+ return validation;
221
+ }
222
+
223
+ // Check if user already has a reserved/unused code
224
+ const existingReservation = await pool.query(`
225
+ SELECT code FROM promo_codes
226
+ WHERE reserved_by = $1 AND status = 'reserved'
227
+ AND (reservation_expires_at IS NULL OR reservation_expires_at > NOW())
228
+ `, [walletAddress]);
229
+
230
+ if (existingReservation.rows.length > 0) {
231
+ return {
232
+ valid: false,
233
+ error: 'You already have a promo code reserved',
234
+ existingCode: existingReservation.rows[0].code
235
+ };
236
+ }
237
+
238
+ // Reserve the code
239
+ const expiresAt = new Date(Date.now() + CODE_RESERVATION_TIMEOUT_MINUTES * 60 * 1000);
240
+
241
+ const result = await pool.query(`
242
+ UPDATE promo_codes
243
+ SET
244
+ status = 'reserved',
245
+ reserved_by = $2,
246
+ reserved_at = NOW(),
247
+ reservation_expires_at = $3
248
+ WHERE code = $1
249
+ AND (status = 'available' OR (status = 'reserved' AND reservation_expires_at < NOW()))
250
+ RETURNING id, code, amount_lamports
251
+ `, [normalizedCode, walletAddress, expiresAt]);
252
+
253
+ if (result.rows.length === 0) {
254
+ return { valid: false, error: 'Could not reserve code - it may have just been claimed' };
255
+ }
256
+
257
+ console.log(`[PromoService] ✅ Code reserved until ${expiresAt.toISOString()}`);
258
+
259
+ return {
260
+ success: true,
261
+ code: result.rows[0].code,
262
+ amountLamports: parseInt(result.rows[0].amount_lamports),
263
+ amountSOL: parseInt(result.rows[0].amount_lamports) / LAMPORTS_PER_SOL,
264
+ expiresAt
265
+ };
266
+
267
+ } catch (error) {
268
+ console.error('[PromoService] Error reserving code:', error.message);
269
+ return { valid: false, error: 'Error reserving promo code' };
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Get user's active promo code (reserved but not yet used)
275
+ * @param {string} walletAddress - User's wallet address
276
+ * @returns {Object|null} - Active promo code or null
277
+ */
278
+ async function getActivePromoForUser(walletAddress) {
279
+ try {
280
+ const result = await pool.query(`
281
+ SELECT id, code, amount_lamports, reserved_at, reservation_expires_at
282
+ FROM promo_codes
283
+ WHERE reserved_by = $1
284
+ AND status = 'reserved'
285
+ AND (reservation_expires_at IS NULL OR reservation_expires_at > NOW())
286
+ `, [walletAddress]);
287
+
288
+ if (result.rows.length === 0) {
289
+ return null;
290
+ }
291
+
292
+ const promo = result.rows[0];
293
+ return {
294
+ id: promo.id,
295
+ code: promo.code,
296
+ amountLamports: parseInt(promo.amount_lamports),
297
+ amountSOL: parseInt(promo.amount_lamports) / LAMPORTS_PER_SOL,
298
+ reservedAt: promo.reserved_at,
299
+ expiresAt: promo.reservation_expires_at
300
+ };
301
+
302
+ } catch (error) {
303
+ console.error('[PromoService] Error getting active promo:', error.message);
304
+ return null;
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Mark a promo code as used (called after successful sponsored join)
310
+ * @param {string} code - The promo code
311
+ * @param {string} walletAddress - User's wallet address
312
+ * @param {string} gameId - Game ID they joined
313
+ * @returns {Object} - Result
314
+ */
315
+ async function markCodeAsUsed(code, walletAddress, gameId) {
316
+ try {
317
+ const normalizedCode = code.trim().toUpperCase();
318
+
319
+ console.log(`[PromoService] 📝 Marking code ${normalizedCode} as used in game ${gameId}`);
320
+
321
+ const result = await pool.query(`
322
+ UPDATE promo_codes
323
+ SET
324
+ status = 'used',
325
+ used_by = $2,
326
+ used_at = NOW(),
327
+ used_in_game = $3
328
+ WHERE code = $1
329
+ AND reserved_by = $2
330
+ AND status = 'reserved'
331
+ RETURNING id, code
332
+ `, [normalizedCode, walletAddress, gameId]);
333
+
334
+ if (result.rows.length === 0) {
335
+ return { success: false, error: 'Code not found or not reserved by this user' };
336
+ }
337
+
338
+ console.log(`[PromoService] ✅ Code marked as used`);
339
+ return { success: true, code: normalizedCode };
340
+
341
+ } catch (error) {
342
+ console.error('[PromoService] Error marking code as used:', error.message);
343
+ return { success: false, error: error.message };
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Record the outcome of a promo game (won, lost, refunded)
349
+ * Called by oracle after game resolution
350
+ * @param {string} gameId - The game ID
351
+ * @param {string} outcome - 'won', 'lost', or 'refunded'
352
+ * @returns {Object} - Result
353
+ */
354
+ async function recordGameOutcome(gameId, outcome) {
355
+ try {
356
+ console.log(`[PromoService] 📊 Recording outcome for game ${gameId}: ${outcome}`);
357
+
358
+ const result = await pool.query(`
359
+ UPDATE promo_codes
360
+ SET
361
+ game_outcome = $2,
362
+ outcome_recorded_at = NOW()
363
+ WHERE used_in_game = $1
364
+ RETURNING id, code, used_by
365
+ `, [gameId, outcome]);
366
+
367
+ if (result.rows.length === 0) {
368
+ // No promo code was used in this game - that's fine
369
+ return { success: true, promoUsed: false };
370
+ }
371
+
372
+ console.log(`[PromoService] ✅ Outcome recorded for code ${result.rows[0].code}`);
373
+ return {
374
+ success: true,
375
+ promoUsed: true,
376
+ code: result.rows[0].code,
377
+ userWallet: result.rows[0].used_by
378
+ };
379
+
380
+ } catch (error) {
381
+ console.error('[PromoService] Error recording outcome:', error.message);
382
+ return { success: false, error: error.message };
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Get promo code statistics
388
+ * @returns {Object} - Stats
389
+ */
390
+ async function getStats() {
391
+ try {
392
+ const result = await pool.query(`
393
+ SELECT
394
+ COUNT(*) as total_codes,
395
+ COUNT(*) FILTER (WHERE status = 'available') as available,
396
+ COUNT(*) FILTER (WHERE status = 'reserved') as reserved,
397
+ COUNT(*) FILTER (WHERE status = 'used') as used,
398
+ COUNT(*) FILTER (WHERE status = 'expired') as expired,
399
+ COUNT(*) FILTER (WHERE game_outcome = 'won') as won_games,
400
+ COUNT(*) FILTER (WHERE game_outcome = 'lost') as lost_games,
401
+ COUNT(*) FILTER (WHERE game_outcome = 'refunded') as refunded_games,
402
+ COALESCE(SUM(amount_lamports) FILTER (WHERE status = 'used'), 0) as total_used_lamports
403
+ FROM promo_codes
404
+ `);
405
+
406
+ const stats = result.rows[0];
407
+ return {
408
+ totalCodes: parseInt(stats.total_codes),
409
+ available: parseInt(stats.available),
410
+ reserved: parseInt(stats.reserved),
411
+ used: parseInt(stats.used),
412
+ expired: parseInt(stats.expired),
413
+ outcomes: {
414
+ won: parseInt(stats.won_games),
415
+ lost: parseInt(stats.lost_games),
416
+ refunded: parseInt(stats.refunded_games)
417
+ },
418
+ totalUsedLamports: parseInt(stats.total_used_lamports),
419
+ totalUsedSOL: parseInt(stats.total_used_lamports) / LAMPORTS_PER_SOL
420
+ };
421
+
422
+ } catch (error) {
423
+ console.error('[PromoService] Error getting stats:', error.message);
424
+ throw error;
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Expire stale reservations (run periodically)
430
+ * @returns {number} - Number of codes expired
431
+ */
432
+ async function expireStaleReservations() {
433
+ try {
434
+ const result = await pool.query(`
435
+ UPDATE promo_codes
436
+ SET
437
+ status = 'available',
438
+ reserved_by = NULL,
439
+ reserved_at = NULL,
440
+ reservation_expires_at = NULL
441
+ WHERE status = 'reserved'
442
+ AND reservation_expires_at < NOW()
443
+ RETURNING id
444
+ `);
445
+
446
+ if (result.rows.length > 0) {
447
+ console.log(`[PromoService] 🧹 Expired ${result.rows.length} stale reservations`);
448
+ }
449
+
450
+ return result.rows.length;
451
+
452
+ } catch (error) {
453
+ console.error('[PromoService] Error expiring reservations:', error.message);
454
+ return 0;
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Get all promo codes (for admin dashboard)
460
+ * @returns {Array} - All codes
461
+ */
462
+ async function getAllCodes() {
463
+ try {
464
+ const result = await pool.query(`
465
+ SELECT
466
+ id, code, amount_lamports, status,
467
+ reserved_by, reserved_at, reservation_expires_at,
468
+ used_by, used_at, used_in_game, game_outcome,
469
+ created_at, expires_at, batch_id
470
+ FROM promo_codes
471
+ ORDER BY
472
+ CASE
473
+ WHEN status = 'reserved' THEN 1
474
+ WHEN status = 'available' THEN 2
475
+ WHEN status = 'used' THEN 3
476
+ ELSE 4
477
+ END,
478
+ created_at DESC
479
+ `);
480
+
481
+ return result.rows.map(row => ({
482
+ id: row.id,
483
+ code: row.code,
484
+ amountLamports: parseInt(row.amount_lamports),
485
+ amountSOL: parseInt(row.amount_lamports) / LAMPORTS_PER_SOL,
486
+ status: row.status,
487
+ reservedBy: row.reserved_by,
488
+ reservedAt: row.reserved_at,
489
+ reservationExpiresAt: row.reservation_expires_at,
490
+ usedBy: row.used_by,
491
+ usedAt: row.used_at,
492
+ usedInGame: row.used_in_game,
493
+ gameOutcome: row.game_outcome,
494
+ createdAt: row.created_at,
495
+ expiresAt: row.expires_at,
496
+ batchId: row.batch_id
497
+ }));
498
+
499
+ } catch (error) {
500
+ console.error('[PromoService] Error getting all codes:', error.message);
501
+ throw error;
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Get promo codes by batch (for admin/tracking)
507
+ * @param {string} batchId - Batch ID
508
+ * @returns {Array} - Codes in batch
509
+ */
510
+ async function getCodesByBatch(batchId) {
511
+ try {
512
+ const result = await pool.query(`
513
+ SELECT
514
+ id, code, amount_lamports, status,
515
+ reserved_by, reserved_at,
516
+ used_by, used_at, used_in_game, game_outcome,
517
+ created_at, expires_at
518
+ FROM promo_codes
519
+ WHERE batch_id = $1
520
+ ORDER BY created_at DESC
521
+ `, [batchId]);
522
+
523
+ return result.rows.map(row => ({
524
+ id: row.id,
525
+ code: row.code,
526
+ amountLamports: parseInt(row.amount_lamports),
527
+ amountSOL: parseInt(row.amount_lamports) / LAMPORTS_PER_SOL,
528
+ status: row.status,
529
+ reservedBy: row.reserved_by,
530
+ reservedAt: row.reserved_at,
531
+ usedBy: row.used_by,
532
+ usedAt: row.used_at,
533
+ usedInGame: row.used_in_game,
534
+ gameOutcome: row.game_outcome,
535
+ createdAt: row.created_at,
536
+ expiresAt: row.expires_at
537
+ }));
538
+
539
+ } catch (error) {
540
+ console.error('[PromoService] Error getting batch codes:', error.message);
541
+ throw error;
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Get user's promo status for the in-app banner and profile page
547
+ * Returns info about their reserved or used promo code
548
+ * @param {string} walletAddress - User's wallet address
549
+ * @returns {Object} - Promo status
550
+ */
551
+ async function getUserPromoStatus(walletAddress) {
552
+ try {
553
+ console.log('[PromoService] getUserPromoStatus for wallet:', walletAddress);
554
+
555
+ // Fetch ALL promo codes for this wallet (reserved + used), joined with games for title
556
+ const result = await pool.query(`
557
+ SELECT
558
+ p.code, p.amount_lamports, p.status,
559
+ p.game_outcome, p.banner_dismissed,
560
+ p.used_in_game, p.used_at,
561
+ g.title AS game_title
562
+ FROM promo_codes p
563
+ LEFT JOIN games g ON g.game_id = p.used_in_game
564
+ WHERE (p.reserved_by = $1 OR p.used_by = $1)
565
+ AND p.status IN ('reserved', 'used')
566
+ AND (
567
+ p.status = 'used'
568
+ OR (p.status = 'reserved' AND (p.reservation_expires_at IS NULL OR p.reservation_expires_at > NOW()))
569
+ )
570
+ ORDER BY
571
+ CASE WHEN p.status = 'reserved' THEN 1 ELSE 2 END,
572
+ p.reserved_at DESC NULLS LAST
573
+ `, [walletAddress]);
574
+
575
+ console.log('[PromoService] Query result rows:', result.rows.length);
576
+
577
+ if (result.rows.length === 0) {
578
+ console.log('[PromoService] No promo found for wallet:', walletAddress);
579
+ return {
580
+ hasActivePromo: false,
581
+ code: null,
582
+ amountSOL: 0,
583
+ status: null,
584
+ gameOutcome: null,
585
+ gameId: null,
586
+ gameTitle: null,
587
+ usedAt: null,
588
+ bannerDismissed: false,
589
+ promoHistory: []
590
+ };
591
+ }
592
+
593
+ // Build full history
594
+ const promoHistory = result.rows.map(row => ({
595
+ code: row.code,
596
+ amountSOL: parseInt(row.amount_lamports) / LAMPORTS_PER_SOL,
597
+ status: row.status,
598
+ gameOutcome: row.game_outcome,
599
+ gameId: row.used_in_game,
600
+ gameTitle: row.game_title || null,
601
+ usedAt: row.used_at,
602
+ }));
603
+
604
+ // The "active" promo is the first one (reserved takes priority, then most recent)
605
+ const active = result.rows[0];
606
+ return {
607
+ hasActivePromo: true,
608
+ code: active.code,
609
+ amountSOL: parseInt(active.amount_lamports) / LAMPORTS_PER_SOL,
610
+ status: active.status,
611
+ gameOutcome: active.game_outcome,
612
+ gameId: active.used_in_game,
613
+ gameTitle: active.game_title || null,
614
+ usedAt: active.used_at,
615
+ bannerDismissed: active.banner_dismissed || false,
616
+ promoHistory
617
+ };
618
+
619
+ } catch (error) {
620
+ console.error('[PromoService] Error getting user promo status:', error.message);
621
+ return {
622
+ hasActivePromo: false,
623
+ code: null,
624
+ amountSOL: 0,
625
+ status: null,
626
+ gameOutcome: null,
627
+ gameId: null,
628
+ gameTitle: null,
629
+ usedAt: null,
630
+ bannerDismissed: false,
631
+ promoHistory: []
632
+ };
633
+ }
634
+ }
635
+
636
+ /**
637
+ * Confirm a promo code reservation after user completes registration
638
+ * This removes the expiration time so the code won't be released
639
+ * @param {string} walletAddress - User's wallet address
640
+ * @returns {Object} - Result
641
+ */
642
+ async function confirmReservation(walletAddress) {
643
+ try {
644
+ const result = await pool.query(`
645
+ UPDATE promo_codes
646
+ SET reservation_expires_at = NULL
647
+ WHERE reserved_by = $1
648
+ AND status = 'reserved'
649
+ RETURNING id, code
650
+ `, [walletAddress]);
651
+
652
+ if (result.rows.length === 0) {
653
+ return { success: false, error: 'No reservation found' };
654
+ }
655
+
656
+ console.log(`[PromoService] ✅ Reservation confirmed for ${walletAddress.slice(0, 8)}... (expiration removed)`);
657
+ return { success: true, code: result.rows[0].code };
658
+
659
+ } catch (error) {
660
+ console.error('[PromoService] Error confirming reservation:', error.message);
661
+ return { success: false, error: error.message };
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Dismiss the promo banner for a user
667
+ * @param {string} walletAddress - User's wallet address
668
+ * @returns {Object} - Result
669
+ */
670
+ async function dismissBanner(walletAddress) {
671
+ try {
672
+ const result = await pool.query(`
673
+ UPDATE promo_codes
674
+ SET banner_dismissed = TRUE
675
+ WHERE (reserved_by = $1 OR used_by = $1)
676
+ AND status IN ('reserved', 'used')
677
+ RETURNING id, code
678
+ `, [walletAddress]);
679
+
680
+ if (result.rows.length === 0) {
681
+ return { success: false, error: 'No active promo code found' };
682
+ }
683
+
684
+ console.log(`[PromoService] ✅ Banner dismissed for ${walletAddress.slice(0, 8)}...`);
685
+ return { success: true };
686
+
687
+ } catch (error) {
688
+ console.error('[PromoService] Error dismissing banner:', error.message);
689
+ return { success: false, error: error.message };
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Release a specific promo code back to available status (admin function)
695
+ * @param {string} code - The promo code to release
696
+ * @returns {Object} - Result
697
+ */
698
+ async function releaseCode(code) {
699
+ try {
700
+ const normalizedCode = code.trim().toUpperCase();
701
+
702
+ const result = await pool.query(`
703
+ UPDATE promo_codes
704
+ SET
705
+ status = 'available',
706
+ reserved_by = NULL,
707
+ reserved_at = NULL,
708
+ reservation_expires_at = NULL
709
+ WHERE code = $1
710
+ AND status = 'reserved'
711
+ RETURNING id, code
712
+ `, [normalizedCode]);
713
+
714
+ if (result.rows.length === 0) {
715
+ return { success: false, error: 'Code not found or not in reserved status' };
716
+ }
717
+
718
+ console.log(`[PromoService] 🔓 Released code ${normalizedCode} back to available`);
719
+ return { success: true, code: normalizedCode };
720
+
721
+ } catch (error) {
722
+ console.error('[PromoService] Error releasing code:', error.message);
723
+ return { success: false, error: error.message };
724
+ }
725
+ }
726
+
727
+ module.exports = {
728
+ // Configuration
729
+ DEFAULT_PROMO_AMOUNT_LAMPORTS,
730
+ LAMPORTS_PER_SOL,
731
+ CODE_RESERVATION_TIMEOUT_MINUTES,
732
+
733
+ // Setup
734
+ ensureTablesExist,
735
+
736
+ // Code generation
737
+ generateBatch,
738
+
739
+ // User-facing
740
+ validateCode,
741
+ reserveCode,
742
+ confirmReservation,
743
+ getActivePromoForUser,
744
+ markCodeAsUsed,
745
+ getUserPromoStatus,
746
+ dismissBanner,
747
+
748
+ // Oracle/game resolution
749
+ recordGameOutcome,
750
+
751
+ // Admin/stats
752
+ getStats,
753
+ getAllCodes,
754
+ getCodesByBatch,
755
+ expireStaleReservations,
756
+ releaseCode
757
+ };