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,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
|
+
};
|