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,1262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analytics Routes
|
|
3
|
+
*
|
|
4
|
+
* Receives analytics events from the frontend and stores them in PostgreSQL.
|
|
5
|
+
* Uses the audit_logs table with enhanced structure for analytics queries.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const express = require('express');
|
|
9
|
+
const router = express.Router();
|
|
10
|
+
const { pool } = require('../services/db'); // Shared database pool
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* POST /api/analytics/events
|
|
14
|
+
*
|
|
15
|
+
* Receive batch of analytics events from frontend
|
|
16
|
+
* Public endpoint - no auth required (events include user context)
|
|
17
|
+
*/
|
|
18
|
+
router.post('/events', async (req, res) => {
|
|
19
|
+
try {
|
|
20
|
+
const { events } = req.body;
|
|
21
|
+
|
|
22
|
+
if (!events || !Array.isArray(events) || events.length === 0) {
|
|
23
|
+
return res.status(400).json({
|
|
24
|
+
success: false,
|
|
25
|
+
error: 'Events array is required'
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Limit batch size to prevent abuse
|
|
30
|
+
if (events.length > 50) {
|
|
31
|
+
return res.status(400).json({
|
|
32
|
+
success: false,
|
|
33
|
+
error: 'Maximum 50 events per batch'
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
// Insert all events in a single transaction
|
|
39
|
+
const client = await pool.connect();
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await client.query('BEGIN');
|
|
43
|
+
|
|
44
|
+
const insertPromises = events.map(event => {
|
|
45
|
+
const {
|
|
46
|
+
eventName,
|
|
47
|
+
eventCategory,
|
|
48
|
+
userId,
|
|
49
|
+
properties,
|
|
50
|
+
context,
|
|
51
|
+
clientTimestamp,
|
|
52
|
+
funnelId,
|
|
53
|
+
funnelStep,
|
|
54
|
+
} = event;
|
|
55
|
+
|
|
56
|
+
// Build metadata object combining properties and context
|
|
57
|
+
const metadata = {
|
|
58
|
+
...properties,
|
|
59
|
+
context: context || {},
|
|
60
|
+
funnelId,
|
|
61
|
+
funnelStep,
|
|
62
|
+
clientTimestamp,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return client.query(
|
|
66
|
+
`INSERT INTO audit_logs (log_type, method, user_id, metadata, created_at)
|
|
67
|
+
VALUES ($1, $2, $3, $4, NOW())`,
|
|
68
|
+
[
|
|
69
|
+
eventName, // log_type = event name
|
|
70
|
+
eventCategory, // method = category
|
|
71
|
+
userId || null, // user_id = wallet address
|
|
72
|
+
JSON.stringify(metadata),
|
|
73
|
+
]
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await Promise.all(insertPromises);
|
|
78
|
+
await client.query('COMMIT');
|
|
79
|
+
|
|
80
|
+
console.log(`[Analytics] Stored ${events.length} events`);
|
|
81
|
+
|
|
82
|
+
return res.json({
|
|
83
|
+
success: true,
|
|
84
|
+
stored: events.length
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
} catch (insertError) {
|
|
88
|
+
await client.query('ROLLBACK');
|
|
89
|
+
throw insertError;
|
|
90
|
+
} finally {
|
|
91
|
+
client.release();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error('[Analytics] Error storing events:', error);
|
|
96
|
+
return res.status(500).json({
|
|
97
|
+
success: false,
|
|
98
|
+
error: 'Failed to store events'
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* GET /api/analytics/events
|
|
105
|
+
*
|
|
106
|
+
* Query events for dashboard/analysis (protected - future admin use)
|
|
107
|
+
*/
|
|
108
|
+
router.get('/events', async (req, res) => {
|
|
109
|
+
try {
|
|
110
|
+
|
|
111
|
+
const {
|
|
112
|
+
eventName,
|
|
113
|
+
category,
|
|
114
|
+
userId,
|
|
115
|
+
startDate,
|
|
116
|
+
endDate,
|
|
117
|
+
limit = 100,
|
|
118
|
+
offset = 0,
|
|
119
|
+
} = req.query;
|
|
120
|
+
|
|
121
|
+
let query = 'SELECT * FROM audit_logs WHERE 1=1';
|
|
122
|
+
const params = [];
|
|
123
|
+
let paramIndex = 1;
|
|
124
|
+
|
|
125
|
+
if (eventName) {
|
|
126
|
+
query += ` AND log_type = $${paramIndex++}`;
|
|
127
|
+
params.push(eventName);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (category) {
|
|
131
|
+
query += ` AND method = $${paramIndex++}`;
|
|
132
|
+
params.push(category);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (userId) {
|
|
136
|
+
query += ` AND user_id = $${paramIndex++}`;
|
|
137
|
+
params.push(userId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (startDate) {
|
|
141
|
+
query += ` AND created_at >= $${paramIndex++}`;
|
|
142
|
+
params.push(startDate);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (endDate) {
|
|
146
|
+
query += ` AND created_at <= $${paramIndex++}`;
|
|
147
|
+
params.push(endDate);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
query += ` ORDER BY created_at DESC`;
|
|
151
|
+
query += ` LIMIT $${paramIndex++} OFFSET $${paramIndex++}`;
|
|
152
|
+
params.push(Math.min(parseInt(limit), 1000), parseInt(offset) || 0);
|
|
153
|
+
|
|
154
|
+
const result = await pool.query(query, params);
|
|
155
|
+
|
|
156
|
+
return res.json({
|
|
157
|
+
success: true,
|
|
158
|
+
events: result.rows,
|
|
159
|
+
count: result.rows.length,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error('[Analytics] Error querying events:', error);
|
|
164
|
+
return res.status(500).json({
|
|
165
|
+
success: false,
|
|
166
|
+
error: 'Failed to query events'
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* GET /api/analytics/funnel/:funnelId
|
|
173
|
+
*
|
|
174
|
+
* Get funnel conversion data with step context
|
|
175
|
+
*/
|
|
176
|
+
router.get('/funnel/:funnelId', async (req, res) => {
|
|
177
|
+
try {
|
|
178
|
+
const { funnelId } = req.params;
|
|
179
|
+
const { startDate, endDate } = req.query;
|
|
180
|
+
|
|
181
|
+
// Default to last 7 days
|
|
182
|
+
const start = startDate || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
183
|
+
const end = endDate || new Date().toISOString();
|
|
184
|
+
|
|
185
|
+
// Get events for this funnel with context from metadata
|
|
186
|
+
// For tutorial_step_completed, we group by stepIndex to get each step separately
|
|
187
|
+
// Use COALESCE for user_id to handle anonymous users (null user_id) by using session_id from context
|
|
188
|
+
const result = await pool.query(
|
|
189
|
+
`SELECT
|
|
190
|
+
log_type as event_name,
|
|
191
|
+
metadata->>'funnelStep' as funnel_step,
|
|
192
|
+
COALESCE(metadata->>'stepTitle', metadata->>'stepIndex', '') as step_context,
|
|
193
|
+
COALESCE((metadata->>'stepIndex')::int, 0) as step_order,
|
|
194
|
+
COUNT(DISTINCT COALESCE(user_id, metadata->'context'->>'sessionId', id::text)) as unique_users,
|
|
195
|
+
COUNT(*) as total_events
|
|
196
|
+
FROM audit_logs
|
|
197
|
+
WHERE metadata->>'funnelId' = $1
|
|
198
|
+
AND created_at >= $2
|
|
199
|
+
AND created_at <= $3
|
|
200
|
+
GROUP BY log_type, metadata->>'funnelStep',
|
|
201
|
+
COALESCE(metadata->>'stepTitle', metadata->>'stepIndex', ''),
|
|
202
|
+
COALESCE((metadata->>'stepIndex')::int, 0)
|
|
203
|
+
ORDER BY MIN(created_at), step_order`,
|
|
204
|
+
[funnelId, start, end]
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
console.log(`[Analytics] Funnel ${funnelId}: found ${result.rows.length} steps`);
|
|
208
|
+
|
|
209
|
+
// Post-process to create meaningful labels
|
|
210
|
+
const processedSteps = result.rows.map(row => {
|
|
211
|
+
let displayName = row.event_name;
|
|
212
|
+
|
|
213
|
+
// Add context for tutorial steps
|
|
214
|
+
if (row.event_name === 'tutorial_step_viewed' && row.step_context) {
|
|
215
|
+
displayName = `Tutorial: ${row.step_context}`;
|
|
216
|
+
} else if (row.event_name === 'tutorial_skipped' && row.step_context) {
|
|
217
|
+
displayName = `Tutorial Skipped at: ${row.step_context}`;
|
|
218
|
+
} else if (row.event_name === 'tutorial_started') {
|
|
219
|
+
displayName = 'Tutorial Started';
|
|
220
|
+
} else if (row.event_name === 'tutorial_completed') {
|
|
221
|
+
displayName = 'Tutorial Completed';
|
|
222
|
+
} else if (row.event_name === 'onboarding_currency_selected' && row.step_context) {
|
|
223
|
+
displayName = `Currency: ${row.step_context}`;
|
|
224
|
+
}
|
|
225
|
+
// Seeker onboarding event display names
|
|
226
|
+
else if (row.event_name === 'welcome_shown') {
|
|
227
|
+
displayName = '📱 Welcome Shown';
|
|
228
|
+
} else if (row.event_name === 'cat_tapped') {
|
|
229
|
+
displayName = '🐱 Cat Tapped';
|
|
230
|
+
} else if (row.event_name === 'tutorial_started' && funnelId === 'seeker_onboarding') {
|
|
231
|
+
displayName = '🚀 Tutorial Started';
|
|
232
|
+
} else if (row.event_name === 'category_selected') {
|
|
233
|
+
displayName = '🎯 Category Selected';
|
|
234
|
+
} else if (row.event_name === 'sport_selected') {
|
|
235
|
+
displayName = '⚽ Sport Selected';
|
|
236
|
+
} else if (row.event_name === 'game_type_selected') {
|
|
237
|
+
displayName = '🎮 Game Selected';
|
|
238
|
+
} else if (row.event_name === 'sports_event_selected') {
|
|
239
|
+
displayName = '🏟️ Match Selected';
|
|
240
|
+
} else if (row.event_name === 'team_selected') {
|
|
241
|
+
displayName = '👥 Team Selected';
|
|
242
|
+
} else if (row.event_name === 'bet_amount_selected') {
|
|
243
|
+
displayName = '💰 Bet Amount Set';
|
|
244
|
+
} else if (row.event_name === 'share_link_clicked') {
|
|
245
|
+
displayName = '🔗 Share Link Clicked';
|
|
246
|
+
} else if (row.event_name === 'step_completed') {
|
|
247
|
+
displayName = '✅ Step Completed';
|
|
248
|
+
} else if (row.event_name === 'back_clicked') {
|
|
249
|
+
displayName = '⬅️ Back Clicked';
|
|
250
|
+
} else if (row.event_name === 'tutorial_completed' && funnelId === 'seeker_onboarding') {
|
|
251
|
+
displayName = '🎉 Tutorial Completed';
|
|
252
|
+
} else if (row.event_name === 'connect4_started') {
|
|
253
|
+
displayName = '🎮 Connect4 Started';
|
|
254
|
+
} else if (row.event_name === 'connect4_move') {
|
|
255
|
+
displayName = '🔵 Connect4 Move';
|
|
256
|
+
} else if (row.event_name === 'connect4_won') {
|
|
257
|
+
displayName = '🏆 Connect4 Won';
|
|
258
|
+
} else if (row.event_name === 'wallet_connected') {
|
|
259
|
+
displayName = '💳 Wallet Connected';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
...row,
|
|
264
|
+
display_name: displayName,
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
return res.json({
|
|
269
|
+
success: true,
|
|
270
|
+
funnelId,
|
|
271
|
+
steps: processedSteps,
|
|
272
|
+
dateRange: { start, end },
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
} catch (error) {
|
|
276
|
+
console.error('[Analytics] Error getting funnel:', error);
|
|
277
|
+
return res.status(500).json({
|
|
278
|
+
success: false,
|
|
279
|
+
error: 'Failed to get funnel data'
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* GET /api/analytics/summary
|
|
286
|
+
*
|
|
287
|
+
* Get high-level analytics summary
|
|
288
|
+
*/
|
|
289
|
+
router.get('/summary', async (req, res) => {
|
|
290
|
+
try {
|
|
291
|
+
const { days = 7 } = req.query;
|
|
292
|
+
|
|
293
|
+
const startDate = new Date(Date.now() - parseInt(days) * 24 * 60 * 60 * 1000).toISOString();
|
|
294
|
+
|
|
295
|
+
// Get summary stats
|
|
296
|
+
const [totalEvents, uniqueUsers, eventsByCategory, eventsByName] = await Promise.all([
|
|
297
|
+
// Total events
|
|
298
|
+
pool.query(
|
|
299
|
+
'SELECT COUNT(*) as count FROM audit_logs WHERE created_at >= $1',
|
|
300
|
+
[startDate]
|
|
301
|
+
),
|
|
302
|
+
// Unique users
|
|
303
|
+
pool.query(
|
|
304
|
+
'SELECT COUNT(DISTINCT user_id) as count FROM audit_logs WHERE created_at >= $1 AND user_id IS NOT NULL',
|
|
305
|
+
[startDate]
|
|
306
|
+
),
|
|
307
|
+
// Events by category
|
|
308
|
+
pool.query(
|
|
309
|
+
`SELECT method as category, COUNT(*) as count
|
|
310
|
+
FROM audit_logs
|
|
311
|
+
WHERE created_at >= $1
|
|
312
|
+
GROUP BY method
|
|
313
|
+
ORDER BY count DESC`,
|
|
314
|
+
[startDate]
|
|
315
|
+
),
|
|
316
|
+
// Top events by name
|
|
317
|
+
pool.query(
|
|
318
|
+
`SELECT log_type as event_name, COUNT(*) as count
|
|
319
|
+
FROM audit_logs
|
|
320
|
+
WHERE created_at >= $1
|
|
321
|
+
GROUP BY log_type
|
|
322
|
+
ORDER BY count DESC
|
|
323
|
+
LIMIT 20`,
|
|
324
|
+
[startDate]
|
|
325
|
+
),
|
|
326
|
+
]);
|
|
327
|
+
|
|
328
|
+
return res.json({
|
|
329
|
+
success: true,
|
|
330
|
+
summary: {
|
|
331
|
+
period: `Last ${days} days`,
|
|
332
|
+
totalEvents: parseInt(totalEvents.rows[0].count),
|
|
333
|
+
uniqueUsers: parseInt(uniqueUsers.rows[0].count),
|
|
334
|
+
byCategory: eventsByCategory.rows,
|
|
335
|
+
topEvents: eventsByName.rows,
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
} catch (error) {
|
|
340
|
+
console.error('[Analytics] Error getting summary:', error);
|
|
341
|
+
return res.status(500).json({
|
|
342
|
+
success: false,
|
|
343
|
+
error: 'Failed to get summary'
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* GET /api/analytics/daily
|
|
350
|
+
*
|
|
351
|
+
* Get daily event counts
|
|
352
|
+
*/
|
|
353
|
+
router.get('/daily', async (req, res) => {
|
|
354
|
+
try {
|
|
355
|
+
const { days = 30, eventName, category } = req.query;
|
|
356
|
+
|
|
357
|
+
let query = `
|
|
358
|
+
SELECT
|
|
359
|
+
DATE(created_at) as date,
|
|
360
|
+
COUNT(*) as total_events,
|
|
361
|
+
COUNT(DISTINCT user_id) as unique_users
|
|
362
|
+
FROM audit_logs
|
|
363
|
+
WHERE created_at >= NOW() - INTERVAL '${parseInt(days)} days'
|
|
364
|
+
`;
|
|
365
|
+
|
|
366
|
+
const params = [];
|
|
367
|
+
let paramIndex = 1;
|
|
368
|
+
|
|
369
|
+
if (eventName) {
|
|
370
|
+
query += ` AND log_type = $${paramIndex++}`;
|
|
371
|
+
params.push(eventName);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (category) {
|
|
375
|
+
query += ` AND method = $${paramIndex++}`;
|
|
376
|
+
params.push(category);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
query += ` GROUP BY DATE(created_at) ORDER BY date DESC`;
|
|
380
|
+
|
|
381
|
+
const result = await pool.query(query, params);
|
|
382
|
+
|
|
383
|
+
return res.json({
|
|
384
|
+
success: true,
|
|
385
|
+
daily: result.rows,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
} catch (error) {
|
|
389
|
+
console.error('[Analytics] Error getting daily stats:', error);
|
|
390
|
+
return res.status(500).json({
|
|
391
|
+
success: false,
|
|
392
|
+
error: 'Failed to get daily stats'
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* GET /api/analytics/chat-social
|
|
399
|
+
*
|
|
400
|
+
* Get chat social features stats (@, #, $)
|
|
401
|
+
*/
|
|
402
|
+
router.get('/chat-social', async (req, res) => {
|
|
403
|
+
try {
|
|
404
|
+
const { days = 7 } = req.query;
|
|
405
|
+
const startDate = new Date(Date.now() - parseInt(days) * 24 * 60 * 60 * 1000).toISOString();
|
|
406
|
+
|
|
407
|
+
// Define all chat social event types
|
|
408
|
+
const chatEvents = [
|
|
409
|
+
'chat_mention_dropdown_opened',
|
|
410
|
+
'chat_mention_selected',
|
|
411
|
+
'chat_animation_dropdown_opened',
|
|
412
|
+
'chat_animation_selected',
|
|
413
|
+
'chat_message_with_features',
|
|
414
|
+
'chat_payment_initiated',
|
|
415
|
+
'chat_payment_completed',
|
|
416
|
+
'chat_payment_failed',
|
|
417
|
+
'chat_payment_cancelled',
|
|
418
|
+
];
|
|
419
|
+
|
|
420
|
+
// Get counts for each event type
|
|
421
|
+
const result = await pool.query(
|
|
422
|
+
`SELECT
|
|
423
|
+
log_type as event_name,
|
|
424
|
+
COUNT(*) as total_events,
|
|
425
|
+
COUNT(DISTINCT COALESCE(user_id, metadata->'context'->>'sessionId', id::text)) as unique_users
|
|
426
|
+
FROM audit_logs
|
|
427
|
+
WHERE log_type = ANY($1)
|
|
428
|
+
AND created_at >= $2
|
|
429
|
+
GROUP BY log_type`,
|
|
430
|
+
[chatEvents, startDate]
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
// Build structured response
|
|
434
|
+
const eventCounts = {};
|
|
435
|
+
result.rows.forEach(row => {
|
|
436
|
+
eventCounts[row.event_name] = {
|
|
437
|
+
total: parseInt(row.total_events),
|
|
438
|
+
unique: parseInt(row.unique_users),
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const stats = {
|
|
443
|
+
mentions: {
|
|
444
|
+
dropdownOpened: eventCounts['chat_mention_dropdown_opened']?.total || 0,
|
|
445
|
+
selected: eventCounts['chat_mention_selected']?.total || 0,
|
|
446
|
+
},
|
|
447
|
+
animations: {
|
|
448
|
+
dropdownOpened: eventCounts['chat_animation_dropdown_opened']?.total || 0,
|
|
449
|
+
selected: eventCounts['chat_animation_selected']?.total || 0,
|
|
450
|
+
},
|
|
451
|
+
payments: {
|
|
452
|
+
initiated: eventCounts['chat_payment_initiated']?.total || 0,
|
|
453
|
+
completed: eventCounts['chat_payment_completed']?.total || 0,
|
|
454
|
+
failed: eventCounts['chat_payment_failed']?.total || 0,
|
|
455
|
+
cancelled: eventCounts['chat_payment_cancelled']?.total || 0,
|
|
456
|
+
},
|
|
457
|
+
messagesWithFeatures: eventCounts['chat_message_with_features']?.total || 0,
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
console.log('[Analytics] Chat social stats:', stats);
|
|
461
|
+
|
|
462
|
+
return res.json({
|
|
463
|
+
success: true,
|
|
464
|
+
stats,
|
|
465
|
+
raw: result.rows,
|
|
466
|
+
period: `Last ${days} days`,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
} catch (error) {
|
|
470
|
+
console.error('[Analytics] Error getting chat social stats:', error);
|
|
471
|
+
return res.status(500).json({
|
|
472
|
+
success: false,
|
|
473
|
+
error: 'Failed to get chat social stats'
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* GET /api/analytics/sol-volume
|
|
480
|
+
*
|
|
481
|
+
* High-performance endpoint to calculate SOL flowing through the system
|
|
482
|
+
* Derived from games database - no blockchain queries needed
|
|
483
|
+
*/
|
|
484
|
+
router.get('/sol-volume', async (req, res) => {
|
|
485
|
+
try {
|
|
486
|
+
const { days = 7 } = req.query;
|
|
487
|
+
let daysInt = parseInt(days);
|
|
488
|
+
|
|
489
|
+
// Cap days to prevent invalid dates (max ~10 years, or use 'all time' mode)
|
|
490
|
+
const isAllTime = daysInt > 3650;
|
|
491
|
+
if (isAllTime) {
|
|
492
|
+
daysInt = 3650; // 10 years max for comparison period
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Current period
|
|
496
|
+
const currentEnd = new Date();
|
|
497
|
+
// For "all time", use epoch start; otherwise calculate from days
|
|
498
|
+
const currentStart = isAllTime
|
|
499
|
+
? new Date('2020-01-01T00:00:00.000Z') // Platform launch date (adjust as needed)
|
|
500
|
+
: new Date(Date.now() - daysInt * 24 * 60 * 60 * 1000);
|
|
501
|
+
|
|
502
|
+
// Previous period for comparison (only meaningful for non-all-time queries)
|
|
503
|
+
const prevEnd = currentStart;
|
|
504
|
+
const prevStart = isAllTime
|
|
505
|
+
? new Date('2019-01-01T00:00:00.000Z') // Year before platform launch
|
|
506
|
+
: new Date(Date.now() - 2 * daysInt * 24 * 60 * 60 * 1000);
|
|
507
|
+
|
|
508
|
+
// Fee constants
|
|
509
|
+
const PLATFORM_FEE_PERCENT = 0.01; // 1%
|
|
510
|
+
const ORACLE_FEE_PERCENT = 0.002; // 0.2%
|
|
511
|
+
const TOTAL_FEE_PERCENT = PLATFORM_FEE_PERCENT + ORACLE_FEE_PERCENT;
|
|
512
|
+
|
|
513
|
+
// Query for current period - calculate everything in a single optimized query
|
|
514
|
+
const currentStatsQuery = `
|
|
515
|
+
SELECT
|
|
516
|
+
COUNT(*) as total_games,
|
|
517
|
+
COUNT(CASE WHEN is_resolved = true THEN 1 END) as resolved_games,
|
|
518
|
+
COUNT(CASE WHEN is_locked = true AND is_resolved = false THEN 1 END) as locked_games,
|
|
519
|
+
COUNT(CASE WHEN is_locked = false AND is_resolved = false THEN 1 END) as pending_games,
|
|
520
|
+
COALESCE(SUM(buy_in), 0) as total_buy_ins,
|
|
521
|
+
COALESCE(SUM(
|
|
522
|
+
buy_in * (
|
|
523
|
+
COALESCE(array_length(home_team_players, 1), 0) +
|
|
524
|
+
COALESCE(array_length(away_team_players, 1), 0)
|
|
525
|
+
)
|
|
526
|
+
), 0) as total_wagered,
|
|
527
|
+
COALESCE(SUM(
|
|
528
|
+
COALESCE(array_length(home_team_players, 1), 0) +
|
|
529
|
+
COALESCE(array_length(away_team_players, 1), 0)
|
|
530
|
+
), 0) as total_players,
|
|
531
|
+
COALESCE(AVG(buy_in), 0) as avg_buy_in,
|
|
532
|
+
COALESCE(AVG(
|
|
533
|
+
COALESCE(array_length(home_team_players, 1), 0) +
|
|
534
|
+
COALESCE(array_length(away_team_players, 1), 0)
|
|
535
|
+
), 0) as avg_players_per_game,
|
|
536
|
+
COUNT(DISTINCT created_by) as unique_creators
|
|
537
|
+
FROM games
|
|
538
|
+
WHERE created_at >= $1 AND created_at < $2
|
|
539
|
+
`;
|
|
540
|
+
|
|
541
|
+
// Query for previous period
|
|
542
|
+
const prevStatsQuery = `
|
|
543
|
+
SELECT
|
|
544
|
+
COUNT(*) as total_games,
|
|
545
|
+
COALESCE(SUM(
|
|
546
|
+
buy_in * (
|
|
547
|
+
COALESCE(array_length(home_team_players, 1), 0) +
|
|
548
|
+
COALESCE(array_length(away_team_players, 1), 0)
|
|
549
|
+
)
|
|
550
|
+
), 0) as total_wagered,
|
|
551
|
+
COALESCE(SUM(
|
|
552
|
+
COALESCE(array_length(home_team_players, 1), 0) +
|
|
553
|
+
COALESCE(array_length(away_team_players, 1), 0)
|
|
554
|
+
), 0) as total_players
|
|
555
|
+
FROM games
|
|
556
|
+
WHERE created_at >= $1 AND created_at < $2
|
|
557
|
+
`;
|
|
558
|
+
|
|
559
|
+
// Query for buy-in distribution (for chart)
|
|
560
|
+
const buyInDistributionQuery = `
|
|
561
|
+
SELECT
|
|
562
|
+
CASE
|
|
563
|
+
WHEN buy_in <= 0.05 THEN '0.01-0.05'
|
|
564
|
+
WHEN buy_in <= 0.1 THEN '0.05-0.1'
|
|
565
|
+
WHEN buy_in <= 0.5 THEN '0.1-0.5'
|
|
566
|
+
WHEN buy_in <= 1 THEN '0.5-1'
|
|
567
|
+
ELSE '1+'
|
|
568
|
+
END as range,
|
|
569
|
+
COUNT(*) as count,
|
|
570
|
+
COALESCE(SUM(
|
|
571
|
+
buy_in * (
|
|
572
|
+
COALESCE(array_length(home_team_players, 1), 0) +
|
|
573
|
+
COALESCE(array_length(away_team_players, 1), 0)
|
|
574
|
+
)
|
|
575
|
+
), 0) as volume
|
|
576
|
+
FROM games
|
|
577
|
+
WHERE created_at >= $1 AND created_at < $2
|
|
578
|
+
GROUP BY
|
|
579
|
+
CASE
|
|
580
|
+
WHEN buy_in <= 0.05 THEN '0.01-0.05'
|
|
581
|
+
WHEN buy_in <= 0.1 THEN '0.05-0.1'
|
|
582
|
+
WHEN buy_in <= 0.5 THEN '0.1-0.5'
|
|
583
|
+
WHEN buy_in <= 1 THEN '0.5-1'
|
|
584
|
+
ELSE '1+'
|
|
585
|
+
END
|
|
586
|
+
ORDER BY MIN(buy_in)
|
|
587
|
+
`;
|
|
588
|
+
|
|
589
|
+
// Query for daily volume (for sparkline)
|
|
590
|
+
const dailyVolumeQuery = `
|
|
591
|
+
SELECT
|
|
592
|
+
DATE(created_at) as date,
|
|
593
|
+
COUNT(*) as games,
|
|
594
|
+
COALESCE(SUM(
|
|
595
|
+
buy_in * (
|
|
596
|
+
COALESCE(array_length(home_team_players, 1), 0) +
|
|
597
|
+
COALESCE(array_length(away_team_players, 1), 0)
|
|
598
|
+
)
|
|
599
|
+
), 0) as volume
|
|
600
|
+
FROM games
|
|
601
|
+
WHERE created_at >= $1 AND created_at < $2
|
|
602
|
+
GROUP BY DATE(created_at)
|
|
603
|
+
ORDER BY date
|
|
604
|
+
`;
|
|
605
|
+
|
|
606
|
+
// Execute all queries in parallel
|
|
607
|
+
const [currentStats, prevStats, buyInDist, dailyVolume] = await Promise.all([
|
|
608
|
+
pool.query(currentStatsQuery, [currentStart.toISOString(), currentEnd.toISOString()]),
|
|
609
|
+
pool.query(prevStatsQuery, [prevStart.toISOString(), prevEnd.toISOString()]),
|
|
610
|
+
pool.query(buyInDistributionQuery, [currentStart.toISOString(), currentEnd.toISOString()]),
|
|
611
|
+
pool.query(dailyVolumeQuery, [currentStart.toISOString(), currentEnd.toISOString()]),
|
|
612
|
+
]);
|
|
613
|
+
|
|
614
|
+
const current = currentStats.rows[0];
|
|
615
|
+
const prev = prevStats.rows[0];
|
|
616
|
+
|
|
617
|
+
// Calculate derived metrics
|
|
618
|
+
const totalWagered = parseFloat(current.total_wagered) || 0;
|
|
619
|
+
const prevTotalWagered = parseFloat(prev.total_wagered) || 0;
|
|
620
|
+
const platformFees = totalWagered * PLATFORM_FEE_PERCENT;
|
|
621
|
+
const oracleFees = totalWagered * ORACLE_FEE_PERCENT;
|
|
622
|
+
const totalFees = totalWagered * TOTAL_FEE_PERCENT;
|
|
623
|
+
const netToPlayers = totalWagered - totalFees;
|
|
624
|
+
|
|
625
|
+
// Calculate percentage changes
|
|
626
|
+
const calculateChange = (current, previous) => {
|
|
627
|
+
if (previous === 0) return current > 0 ? 100 : null;
|
|
628
|
+
return Math.round(((current - previous) / previous) * 100);
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
const response = {
|
|
632
|
+
success: true,
|
|
633
|
+
period: {
|
|
634
|
+
days: daysInt,
|
|
635
|
+
start: currentStart.toISOString(),
|
|
636
|
+
end: currentEnd.toISOString(),
|
|
637
|
+
},
|
|
638
|
+
volume: {
|
|
639
|
+
totalWagered: parseFloat(totalWagered.toFixed(4)),
|
|
640
|
+
platformFees: parseFloat(platformFees.toFixed(4)),
|
|
641
|
+
oracleFees: parseFloat(oracleFees.toFixed(4)),
|
|
642
|
+
totalFees: parseFloat(totalFees.toFixed(4)),
|
|
643
|
+
netToPlayers: parseFloat(netToPlayers.toFixed(4)),
|
|
644
|
+
},
|
|
645
|
+
games: {
|
|
646
|
+
total: parseInt(current.total_games) || 0,
|
|
647
|
+
resolved: parseInt(current.resolved_games) || 0,
|
|
648
|
+
locked: parseInt(current.locked_games) || 0,
|
|
649
|
+
pending: parseInt(current.pending_games) || 0,
|
|
650
|
+
},
|
|
651
|
+
players: {
|
|
652
|
+
total: parseInt(current.total_players) || 0,
|
|
653
|
+
uniqueCreators: parseInt(current.unique_creators) || 0,
|
|
654
|
+
avgPerGame: parseFloat(parseFloat(current.avg_players_per_game).toFixed(1)) || 0,
|
|
655
|
+
},
|
|
656
|
+
averages: {
|
|
657
|
+
buyIn: parseFloat(parseFloat(current.avg_buy_in).toFixed(4)) || 0,
|
|
658
|
+
potSize: parseInt(current.total_games) > 0
|
|
659
|
+
? parseFloat((totalWagered / parseInt(current.total_games)).toFixed(4))
|
|
660
|
+
: 0,
|
|
661
|
+
},
|
|
662
|
+
comparison: {
|
|
663
|
+
volumeChange: calculateChange(totalWagered, prevTotalWagered),
|
|
664
|
+
gamesChange: calculateChange(parseInt(current.total_games), parseInt(prev.total_games)),
|
|
665
|
+
playersChange: calculateChange(parseInt(current.total_players), parseInt(prev.total_players)),
|
|
666
|
+
previousVolume: parseFloat(prevTotalWagered.toFixed(4)),
|
|
667
|
+
},
|
|
668
|
+
distribution: buyInDist.rows.map(row => ({
|
|
669
|
+
range: row.range,
|
|
670
|
+
count: parseInt(row.count),
|
|
671
|
+
volume: parseFloat(parseFloat(row.volume).toFixed(4)),
|
|
672
|
+
})),
|
|
673
|
+
daily: dailyVolume.rows.map(row => ({
|
|
674
|
+
date: row.date,
|
|
675
|
+
games: parseInt(row.games),
|
|
676
|
+
volume: parseFloat(parseFloat(row.volume).toFixed(4)),
|
|
677
|
+
})),
|
|
678
|
+
feeRates: {
|
|
679
|
+
platform: PLATFORM_FEE_PERCENT * 100,
|
|
680
|
+
oracle: ORACLE_FEE_PERCENT * 100,
|
|
681
|
+
total: TOTAL_FEE_PERCENT * 100,
|
|
682
|
+
},
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
console.log('[Analytics] SOL Volume:', {
|
|
686
|
+
totalWagered: response.volume.totalWagered,
|
|
687
|
+
games: response.games.total,
|
|
688
|
+
players: response.players.total,
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
return res.json(response);
|
|
692
|
+
|
|
693
|
+
} catch (error) {
|
|
694
|
+
console.error('[Analytics] Error getting SOL volume:', error);
|
|
695
|
+
return res.status(500).json({
|
|
696
|
+
success: false,
|
|
697
|
+
error: 'Failed to get SOL volume data'
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* GET /api/analytics/phantom-warning
|
|
704
|
+
*
|
|
705
|
+
* Get Phantom wallet warning stats
|
|
706
|
+
*/
|
|
707
|
+
router.get('/phantom-warning', async (req, res) => {
|
|
708
|
+
try {
|
|
709
|
+
const { days = 7 } = req.query;
|
|
710
|
+
const startDate = new Date(Date.now() - parseInt(days) * 24 * 60 * 60 * 1000).toISOString();
|
|
711
|
+
|
|
712
|
+
// Define Phantom warning event types
|
|
713
|
+
const phantomEvents = [
|
|
714
|
+
'phantom_warning_shown',
|
|
715
|
+
'phantom_warning_acknowledged',
|
|
716
|
+
'phantom_warning_cancelled',
|
|
717
|
+
];
|
|
718
|
+
|
|
719
|
+
// Get counts for each event type
|
|
720
|
+
const result = await pool.query(
|
|
721
|
+
`SELECT
|
|
722
|
+
log_type as event_name,
|
|
723
|
+
COUNT(*) as total_events,
|
|
724
|
+
COUNT(DISTINCT COALESCE(user_id, metadata->'context'->>'sessionId', id::text)) as unique_users
|
|
725
|
+
FROM audit_logs
|
|
726
|
+
WHERE log_type = ANY($1)
|
|
727
|
+
AND created_at >= $2
|
|
728
|
+
GROUP BY log_type`,
|
|
729
|
+
[phantomEvents, startDate]
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
// Build structured response
|
|
733
|
+
const eventCounts = {};
|
|
734
|
+
result.rows.forEach(row => {
|
|
735
|
+
eventCounts[row.event_name] = {
|
|
736
|
+
total: parseInt(row.total_events),
|
|
737
|
+
unique: parseInt(row.unique_users),
|
|
738
|
+
};
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
const stats = {
|
|
742
|
+
shown: eventCounts['phantom_warning_shown']?.total || 0,
|
|
743
|
+
acknowledged: eventCounts['phantom_warning_acknowledged']?.total || 0,
|
|
744
|
+
cancelled: eventCounts['phantom_warning_cancelled']?.total || 0,
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
console.log('[Analytics] Phantom warning stats:', stats);
|
|
748
|
+
|
|
749
|
+
return res.json({
|
|
750
|
+
success: true,
|
|
751
|
+
stats,
|
|
752
|
+
raw: result.rows,
|
|
753
|
+
period: `Last ${days} days`,
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
} catch (error) {
|
|
757
|
+
console.error('[Analytics] Error getting phantom warning stats:', error);
|
|
758
|
+
return res.status(500).json({
|
|
759
|
+
success: false,
|
|
760
|
+
error: 'Failed to get phantom warning stats'
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* GET /api/analytics/comparison
|
|
767
|
+
*
|
|
768
|
+
* Get comparison data between current period and previous period
|
|
769
|
+
* for all funnels to show change percentages
|
|
770
|
+
*/
|
|
771
|
+
router.get('/comparison', async (req, res) => {
|
|
772
|
+
try {
|
|
773
|
+
const { days = 7 } = req.query;
|
|
774
|
+
const daysInt = parseInt(days);
|
|
775
|
+
|
|
776
|
+
// Current period: now - days ago
|
|
777
|
+
const currentEnd = new Date();
|
|
778
|
+
const currentStart = new Date(Date.now() - daysInt * 24 * 60 * 60 * 1000);
|
|
779
|
+
|
|
780
|
+
// Previous period: days ago - 2*days ago
|
|
781
|
+
const prevEnd = currentStart;
|
|
782
|
+
const prevStart = new Date(Date.now() - 2 * daysInt * 24 * 60 * 60 * 1000);
|
|
783
|
+
|
|
784
|
+
// Define funnel IDs to track
|
|
785
|
+
const funnelIds = ['bet_creation', 'game_join', 'chat_social', 'social', 'user_onboarding', 'registration', 'invitation_flow'];
|
|
786
|
+
|
|
787
|
+
// Chat social events
|
|
788
|
+
const chatEvents = [
|
|
789
|
+
'chat_mention_dropdown_opened', 'chat_mention_selected',
|
|
790
|
+
'chat_animation_dropdown_opened', 'chat_animation_selected',
|
|
791
|
+
'chat_payment_initiated', 'chat_payment_completed',
|
|
792
|
+
];
|
|
793
|
+
|
|
794
|
+
// Social events
|
|
795
|
+
const socialEvents = [
|
|
796
|
+
'social_page_viewed', 'social_search_performed',
|
|
797
|
+
'friend_request_sent', 'friend_request_accepted', 'friend_request_declined',
|
|
798
|
+
'friend_removed', 'social_user_profile_viewed',
|
|
799
|
+
];
|
|
800
|
+
|
|
801
|
+
// Get funnel totals for current and previous periods
|
|
802
|
+
const [currentFunnels, prevFunnels, currentChat, prevChat, currentSocial, prevSocial] = await Promise.all([
|
|
803
|
+
// Current period funnel totals
|
|
804
|
+
pool.query(
|
|
805
|
+
`SELECT
|
|
806
|
+
metadata->>'funnelId' as funnel_id,
|
|
807
|
+
COUNT(*) as total_events
|
|
808
|
+
FROM audit_logs
|
|
809
|
+
WHERE metadata->>'funnelId' = ANY($1)
|
|
810
|
+
AND created_at >= $2 AND created_at < $3
|
|
811
|
+
GROUP BY metadata->>'funnelId'`,
|
|
812
|
+
[funnelIds, currentStart.toISOString(), currentEnd.toISOString()]
|
|
813
|
+
),
|
|
814
|
+
// Previous period funnel totals
|
|
815
|
+
pool.query(
|
|
816
|
+
`SELECT
|
|
817
|
+
metadata->>'funnelId' as funnel_id,
|
|
818
|
+
COUNT(*) as total_events
|
|
819
|
+
FROM audit_logs
|
|
820
|
+
WHERE metadata->>'funnelId' = ANY($1)
|
|
821
|
+
AND created_at >= $2 AND created_at < $3
|
|
822
|
+
GROUP BY metadata->>'funnelId'`,
|
|
823
|
+
[funnelIds, prevStart.toISOString(), prevEnd.toISOString()]
|
|
824
|
+
),
|
|
825
|
+
// Current period chat social
|
|
826
|
+
pool.query(
|
|
827
|
+
`SELECT COUNT(*) as total FROM audit_logs
|
|
828
|
+
WHERE log_type = ANY($1) AND created_at >= $2 AND created_at < $3`,
|
|
829
|
+
[chatEvents, currentStart.toISOString(), currentEnd.toISOString()]
|
|
830
|
+
),
|
|
831
|
+
// Previous period chat social
|
|
832
|
+
pool.query(
|
|
833
|
+
`SELECT COUNT(*) as total FROM audit_logs
|
|
834
|
+
WHERE log_type = ANY($1) AND created_at >= $2 AND created_at < $3`,
|
|
835
|
+
[chatEvents, prevStart.toISOString(), prevEnd.toISOString()]
|
|
836
|
+
),
|
|
837
|
+
// Current period social page
|
|
838
|
+
pool.query(
|
|
839
|
+
`SELECT COUNT(*) as total FROM audit_logs
|
|
840
|
+
WHERE log_type = ANY($1) AND created_at >= $2 AND created_at < $3`,
|
|
841
|
+
[socialEvents, currentStart.toISOString(), currentEnd.toISOString()]
|
|
842
|
+
),
|
|
843
|
+
// Previous period social page
|
|
844
|
+
pool.query(
|
|
845
|
+
`SELECT COUNT(*) as total FROM audit_logs
|
|
846
|
+
WHERE log_type = ANY($1) AND created_at >= $2 AND created_at < $3`,
|
|
847
|
+
[socialEvents, prevStart.toISOString(), prevEnd.toISOString()]
|
|
848
|
+
),
|
|
849
|
+
]);
|
|
850
|
+
|
|
851
|
+
// Build comparison object
|
|
852
|
+
const currentFunnelMap = {};
|
|
853
|
+
currentFunnels.rows.forEach(row => {
|
|
854
|
+
currentFunnelMap[row.funnel_id] = parseInt(row.total_events);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
const prevFunnelMap = {};
|
|
858
|
+
prevFunnels.rows.forEach(row => {
|
|
859
|
+
prevFunnelMap[row.funnel_id] = parseInt(row.total_events);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
const comparison = {
|
|
863
|
+
betCreation: {
|
|
864
|
+
current: currentFunnelMap['bet_creation'] || 0,
|
|
865
|
+
previous: prevFunnelMap['bet_creation'] || 0,
|
|
866
|
+
},
|
|
867
|
+
gameJoin: {
|
|
868
|
+
current: currentFunnelMap['game_join'] || 0,
|
|
869
|
+
previous: prevFunnelMap['game_join'] || 0,
|
|
870
|
+
},
|
|
871
|
+
chatSocial: {
|
|
872
|
+
current: parseInt(currentChat.rows[0]?.total) || 0,
|
|
873
|
+
previous: parseInt(prevChat.rows[0]?.total) || 0,
|
|
874
|
+
},
|
|
875
|
+
social: {
|
|
876
|
+
current: parseInt(currentSocial.rows[0]?.total) || 0,
|
|
877
|
+
previous: parseInt(prevSocial.rows[0]?.total) || 0,
|
|
878
|
+
},
|
|
879
|
+
onboarding: {
|
|
880
|
+
current: currentFunnelMap['user_onboarding'] || 0,
|
|
881
|
+
previous: prevFunnelMap['user_onboarding'] || 0,
|
|
882
|
+
},
|
|
883
|
+
registration: {
|
|
884
|
+
current: currentFunnelMap['registration'] || 0,
|
|
885
|
+
previous: prevFunnelMap['registration'] || 0,
|
|
886
|
+
},
|
|
887
|
+
invitationFlow: {
|
|
888
|
+
current: currentFunnelMap['invitation_flow'] || 0,
|
|
889
|
+
previous: prevFunnelMap['invitation_flow'] || 0,
|
|
890
|
+
},
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
console.log('[Analytics] Comparison data:', comparison);
|
|
894
|
+
|
|
895
|
+
return res.json({
|
|
896
|
+
success: true,
|
|
897
|
+
comparison,
|
|
898
|
+
periods: {
|
|
899
|
+
current: { start: currentStart.toISOString(), end: currentEnd.toISOString() },
|
|
900
|
+
previous: { start: prevStart.toISOString(), end: prevEnd.toISOString() },
|
|
901
|
+
},
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
} catch (error) {
|
|
905
|
+
console.error('[Analytics] Error getting comparison:', error);
|
|
906
|
+
return res.status(500).json({
|
|
907
|
+
success: false,
|
|
908
|
+
error: 'Failed to get comparison data'
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* GET /api/analytics/cohort-retention
|
|
915
|
+
*
|
|
916
|
+
* Get cohort retention analysis showing user retention over time
|
|
917
|
+
* grouped by signup week or month
|
|
918
|
+
*/
|
|
919
|
+
router.get('/cohort-retention', async (req, res) => {
|
|
920
|
+
try {
|
|
921
|
+
const { period = 'weekly', source = 'all', limit = 12 } = req.query;
|
|
922
|
+
const limitInt = Math.min(parseInt(limit), 52); // Max 52 cohorts (1 year weekly)
|
|
923
|
+
|
|
924
|
+
// Validate period
|
|
925
|
+
if (!['weekly', 'monthly'].includes(period)) {
|
|
926
|
+
return res.status(400).json({
|
|
927
|
+
success: false,
|
|
928
|
+
error: 'Period must be "weekly" or "monthly"',
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Build the cohort retention query
|
|
933
|
+
const cohortQuery = `
|
|
934
|
+
WITH signups AS (
|
|
935
|
+
-- Get first registration event per user
|
|
936
|
+
SELECT
|
|
937
|
+
user_id,
|
|
938
|
+
MIN(DATE(created_at)) as signup_date,
|
|
939
|
+
MIN(metadata->>'referralCode') as referral_code
|
|
940
|
+
FROM audit_logs
|
|
941
|
+
WHERE log_type = 'registration_completed'
|
|
942
|
+
AND user_id IS NOT NULL
|
|
943
|
+
GROUP BY user_id
|
|
944
|
+
),
|
|
945
|
+
cohorts AS (
|
|
946
|
+
-- Group signups into weekly or monthly cohorts
|
|
947
|
+
SELECT
|
|
948
|
+
DATE_TRUNC('${period === 'weekly' ? 'week' : 'month'}', signup_date) as cohort_period,
|
|
949
|
+
user_id,
|
|
950
|
+
signup_date,
|
|
951
|
+
CASE
|
|
952
|
+
WHEN referral_code IS NOT NULL AND referral_code != '' THEN 'referral'
|
|
953
|
+
ELSE 'organic'
|
|
954
|
+
END as user_source
|
|
955
|
+
FROM signups
|
|
956
|
+
),
|
|
957
|
+
user_activity AS (
|
|
958
|
+
-- Get only BET-RELATED activity after signup (core action retention)
|
|
959
|
+
-- A user is "retained" only if they placed a bet, not just opened the app
|
|
960
|
+
SELECT
|
|
961
|
+
c.cohort_period,
|
|
962
|
+
c.user_id,
|
|
963
|
+
c.signup_date,
|
|
964
|
+
c.user_source,
|
|
965
|
+
DATE(al.created_at) as activity_date,
|
|
966
|
+
al.created_at - c.signup_date as time_since_signup
|
|
967
|
+
FROM cohorts c
|
|
968
|
+
LEFT JOIN audit_logs al ON al.user_id = c.user_id
|
|
969
|
+
AND DATE(al.created_at) >= c.signup_date
|
|
970
|
+
-- ONLY count bet-related actions as "retained"
|
|
971
|
+
AND al.log_type IN (
|
|
972
|
+
'bet_creation_completed', -- Created a bet (sports)
|
|
973
|
+
'join_game_completed', -- Joined an existing bet
|
|
974
|
+
'billiards_create_completed', -- Created pool game
|
|
975
|
+
'billiards_join_completed' -- Joined pool game
|
|
976
|
+
)
|
|
977
|
+
WHERE $1 = 'all' OR c.user_source = $1
|
|
978
|
+
),
|
|
979
|
+
retention_calc AS (
|
|
980
|
+
-- Calculate retention metrics
|
|
981
|
+
SELECT
|
|
982
|
+
cohort_period,
|
|
983
|
+
user_source,
|
|
984
|
+
COUNT(DISTINCT user_id) as total_users,
|
|
985
|
+
-- Day 1 retention (24-48 hours)
|
|
986
|
+
COUNT(DISTINCT CASE
|
|
987
|
+
WHEN time_since_signup >= INTERVAL '1 day'
|
|
988
|
+
AND time_since_signup < INTERVAL '2 days'
|
|
989
|
+
THEN user_id
|
|
990
|
+
END) as d1_users,
|
|
991
|
+
-- Day 7 retention (7-8 days)
|
|
992
|
+
COUNT(DISTINCT CASE
|
|
993
|
+
WHEN time_since_signup >= INTERVAL '7 days'
|
|
994
|
+
AND time_since_signup < INTERVAL '8 days'
|
|
995
|
+
THEN user_id
|
|
996
|
+
END) as d7_users,
|
|
997
|
+
-- Day 14 retention (14-15 days)
|
|
998
|
+
COUNT(DISTINCT CASE
|
|
999
|
+
WHEN time_since_signup >= INTERVAL '14 days'
|
|
1000
|
+
AND time_since_signup < INTERVAL '15 days'
|
|
1001
|
+
THEN user_id
|
|
1002
|
+
END) as d14_users,
|
|
1003
|
+
-- Day 30 retention (30-31 days)
|
|
1004
|
+
COUNT(DISTINCT CASE
|
|
1005
|
+
WHEN time_since_signup >= INTERVAL '30 days'
|
|
1006
|
+
AND time_since_signup < INTERVAL '31 days'
|
|
1007
|
+
THEN user_id
|
|
1008
|
+
END) as d30_users
|
|
1009
|
+
FROM user_activity
|
|
1010
|
+
GROUP BY cohort_period, user_source
|
|
1011
|
+
)
|
|
1012
|
+
SELECT
|
|
1013
|
+
cohort_period,
|
|
1014
|
+
total_users,
|
|
1015
|
+
d1_users,
|
|
1016
|
+
ROUND(100.0 * d1_users / NULLIF(total_users, 0), 1) as d1_pct,
|
|
1017
|
+
d7_users,
|
|
1018
|
+
ROUND(100.0 * d7_users / NULLIF(total_users, 0), 1) as d7_pct,
|
|
1019
|
+
d14_users,
|
|
1020
|
+
ROUND(100.0 * d14_users / NULLIF(total_users, 0), 1) as d14_pct,
|
|
1021
|
+
d30_users,
|
|
1022
|
+
ROUND(100.0 * d30_users / NULLIF(total_users, 0), 1) as d30_pct,
|
|
1023
|
+
user_source
|
|
1024
|
+
FROM retention_calc
|
|
1025
|
+
WHERE total_users > 0
|
|
1026
|
+
ORDER BY cohort_period DESC
|
|
1027
|
+
LIMIT $2
|
|
1028
|
+
`;
|
|
1029
|
+
|
|
1030
|
+
const result = await pool.query(cohortQuery, [source, limitInt]);
|
|
1031
|
+
|
|
1032
|
+
// Format the response with readable date ranges
|
|
1033
|
+
const cohorts = result.rows.map(row => {
|
|
1034
|
+
const cohortDate = new Date(row.cohort_period);
|
|
1035
|
+
let dateRange;
|
|
1036
|
+
|
|
1037
|
+
if (period === 'weekly') {
|
|
1038
|
+
const endDate = new Date(cohortDate);
|
|
1039
|
+
endDate.setDate(endDate.getDate() + 6);
|
|
1040
|
+
// Use UTC methods to avoid timezone issues
|
|
1041
|
+
dateRange = `${cohortDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })} - ${endDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })}`;
|
|
1042
|
+
} else {
|
|
1043
|
+
// Use UTC timezone to ensure correct month display
|
|
1044
|
+
dateRange = cohortDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
return {
|
|
1048
|
+
signup_cohort: row.cohort_period,
|
|
1049
|
+
signup_date_range: dateRange,
|
|
1050
|
+
total_signups: parseInt(row.total_users),
|
|
1051
|
+
d1_users: parseInt(row.d1_users),
|
|
1052
|
+
d1_pct: parseFloat(row.d1_pct) || 0,
|
|
1053
|
+
d7_users: parseInt(row.d7_users),
|
|
1054
|
+
d7_pct: parseFloat(row.d7_pct) || 0,
|
|
1055
|
+
d14_users: parseInt(row.d14_users),
|
|
1056
|
+
d14_pct: parseFloat(row.d14_pct) || 0,
|
|
1057
|
+
d30_users: parseInt(row.d30_users),
|
|
1058
|
+
d30_pct: parseFloat(row.d30_pct) || 0,
|
|
1059
|
+
source: source === 'all' ? 'all' : row.user_source,
|
|
1060
|
+
};
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
console.log(`[Analytics] Cohort retention: ${cohorts.length} cohorts (${period}, source: ${source})`);
|
|
1064
|
+
|
|
1065
|
+
return res.json({
|
|
1066
|
+
success: true,
|
|
1067
|
+
period,
|
|
1068
|
+
source,
|
|
1069
|
+
cohorts,
|
|
1070
|
+
total_cohorts: cohorts.length,
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
console.error('[Analytics] Error getting cohort retention:', error);
|
|
1075
|
+
return res.status(500).json({
|
|
1076
|
+
success: false,
|
|
1077
|
+
error: 'Failed to get cohort retention data',
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* GET /api/analytics/cohort-retention/csv
|
|
1084
|
+
*
|
|
1085
|
+
* Export cohort retention data as CSV
|
|
1086
|
+
*/
|
|
1087
|
+
router.get('/cohort-retention/csv', async (req, res) => {
|
|
1088
|
+
try {
|
|
1089
|
+
const { period = 'weekly', source = 'all', limit = 52 } = req.query;
|
|
1090
|
+
|
|
1091
|
+
// Get the cohort data (reuse the same endpoint logic)
|
|
1092
|
+
const cohortResponse = await new Promise((resolve, reject) => {
|
|
1093
|
+
// Call the cohort-retention endpoint internally
|
|
1094
|
+
const mockReq = { query: { period, source, limit } };
|
|
1095
|
+
const mockRes = {
|
|
1096
|
+
json: (data) => resolve(data),
|
|
1097
|
+
status: () => mockRes,
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
// Use the same query logic
|
|
1101
|
+
pool.query(`
|
|
1102
|
+
WITH signups AS (
|
|
1103
|
+
SELECT
|
|
1104
|
+
user_id,
|
|
1105
|
+
MIN(DATE(created_at)) as signup_date,
|
|
1106
|
+
MIN(metadata->>'referralCode') as referral_code
|
|
1107
|
+
FROM audit_logs
|
|
1108
|
+
WHERE log_type = 'registration_completed'
|
|
1109
|
+
AND user_id IS NOT NULL
|
|
1110
|
+
GROUP BY user_id
|
|
1111
|
+
),
|
|
1112
|
+
cohorts AS (
|
|
1113
|
+
SELECT
|
|
1114
|
+
DATE_TRUNC('${period === 'weekly' ? 'week' : 'month'}', signup_date) as cohort_period,
|
|
1115
|
+
user_id,
|
|
1116
|
+
signup_date,
|
|
1117
|
+
CASE
|
|
1118
|
+
WHEN referral_code IS NOT NULL AND referral_code != '' THEN 'referral'
|
|
1119
|
+
ELSE 'organic'
|
|
1120
|
+
END as user_source
|
|
1121
|
+
FROM signups
|
|
1122
|
+
),
|
|
1123
|
+
user_activity AS (
|
|
1124
|
+
-- Get only BET-RELATED activity after signup (core action retention)
|
|
1125
|
+
SELECT
|
|
1126
|
+
c.cohort_period,
|
|
1127
|
+
c.user_id,
|
|
1128
|
+
c.signup_date,
|
|
1129
|
+
c.user_source,
|
|
1130
|
+
DATE(al.created_at) as activity_date,
|
|
1131
|
+
al.created_at - c.signup_date as time_since_signup
|
|
1132
|
+
FROM cohorts c
|
|
1133
|
+
LEFT JOIN audit_logs al ON al.user_id = c.user_id
|
|
1134
|
+
AND DATE(al.created_at) >= c.signup_date
|
|
1135
|
+
-- ONLY count bet-related actions as "retained"
|
|
1136
|
+
AND al.log_type IN (
|
|
1137
|
+
'bet_creation_completed', -- Created a bet (sports)
|
|
1138
|
+
'join_game_completed', -- Joined an existing bet
|
|
1139
|
+
'billiards_create_completed', -- Created pool game
|
|
1140
|
+
'billiards_join_completed' -- Joined pool game
|
|
1141
|
+
)
|
|
1142
|
+
WHERE $1 = 'all' OR c.user_source = $1
|
|
1143
|
+
),
|
|
1144
|
+
retention_calc AS (
|
|
1145
|
+
SELECT
|
|
1146
|
+
cohort_period,
|
|
1147
|
+
user_source,
|
|
1148
|
+
COUNT(DISTINCT user_id) as total_users,
|
|
1149
|
+
COUNT(DISTINCT CASE
|
|
1150
|
+
WHEN time_since_signup >= INTERVAL '1 day'
|
|
1151
|
+
AND time_since_signup < INTERVAL '2 days'
|
|
1152
|
+
THEN user_id
|
|
1153
|
+
END) as d1_users,
|
|
1154
|
+
COUNT(DISTINCT CASE
|
|
1155
|
+
WHEN time_since_signup >= INTERVAL '7 days'
|
|
1156
|
+
AND time_since_signup < INTERVAL '8 days'
|
|
1157
|
+
THEN user_id
|
|
1158
|
+
END) as d7_users,
|
|
1159
|
+
COUNT(DISTINCT CASE
|
|
1160
|
+
WHEN time_since_signup >= INTERVAL '14 days'
|
|
1161
|
+
AND time_since_signup < INTERVAL '15 days'
|
|
1162
|
+
THEN user_id
|
|
1163
|
+
END) as d14_users,
|
|
1164
|
+
COUNT(DISTINCT CASE
|
|
1165
|
+
WHEN time_since_signup >= INTERVAL '30 days'
|
|
1166
|
+
AND time_since_signup < INTERVAL '31 days'
|
|
1167
|
+
THEN user_id
|
|
1168
|
+
END) as d30_users
|
|
1169
|
+
FROM user_activity
|
|
1170
|
+
GROUP BY cohort_period, user_source
|
|
1171
|
+
)
|
|
1172
|
+
SELECT
|
|
1173
|
+
cohort_period,
|
|
1174
|
+
total_users,
|
|
1175
|
+
d1_users,
|
|
1176
|
+
ROUND(100.0 * d1_users / NULLIF(total_users, 0), 1) as d1_pct,
|
|
1177
|
+
d7_users,
|
|
1178
|
+
ROUND(100.0 * d7_users / NULLIF(total_users, 0), 1) as d7_pct,
|
|
1179
|
+
d14_users,
|
|
1180
|
+
ROUND(100.0 * d14_users / NULLIF(total_users, 0), 1) as d14_pct,
|
|
1181
|
+
d30_users,
|
|
1182
|
+
ROUND(100.0 * d30_users / NULLIF(total_users, 0), 1) as d30_pct,
|
|
1183
|
+
user_source
|
|
1184
|
+
FROM retention_calc
|
|
1185
|
+
WHERE total_users > 0
|
|
1186
|
+
ORDER BY cohort_period DESC
|
|
1187
|
+
LIMIT $2
|
|
1188
|
+
`, [source, Math.min(parseInt(limit), 52)])
|
|
1189
|
+
.then(result => resolve(result.rows))
|
|
1190
|
+
.catch(reject);
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
// Build CSV content
|
|
1194
|
+
const headers = [
|
|
1195
|
+
'signup_cohort',
|
|
1196
|
+
'signup_date_range',
|
|
1197
|
+
'total_signups',
|
|
1198
|
+
'd1_users',
|
|
1199
|
+
'd1_pct',
|
|
1200
|
+
'd7_users',
|
|
1201
|
+
'd7_pct',
|
|
1202
|
+
'd14_users',
|
|
1203
|
+
'd14_pct',
|
|
1204
|
+
'd30_users',
|
|
1205
|
+
'd30_pct',
|
|
1206
|
+
'source',
|
|
1207
|
+
];
|
|
1208
|
+
|
|
1209
|
+
let csv = headers.join(',') + '\n';
|
|
1210
|
+
|
|
1211
|
+
cohortResponse.forEach(row => {
|
|
1212
|
+
const cohortDate = new Date(row.cohort_period);
|
|
1213
|
+
let dateRange;
|
|
1214
|
+
|
|
1215
|
+
if (period === 'weekly') {
|
|
1216
|
+
const endDate = new Date(cohortDate);
|
|
1217
|
+
endDate.setDate(endDate.getDate() + 6);
|
|
1218
|
+
// Use UTC methods to avoid timezone issues
|
|
1219
|
+
dateRange = `${cohortDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })} - ${endDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })}`;
|
|
1220
|
+
} else {
|
|
1221
|
+
// Use UTC timezone to ensure correct month display
|
|
1222
|
+
dateRange = cohortDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const values = [
|
|
1226
|
+
`"${period === 'weekly' ? 'Week of ' : ''}${cohortDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })}"`,
|
|
1227
|
+
`"${dateRange}"`,
|
|
1228
|
+
parseInt(row.total_users),
|
|
1229
|
+
parseInt(row.d1_users),
|
|
1230
|
+
parseFloat(row.d1_pct) || 0,
|
|
1231
|
+
parseInt(row.d7_users),
|
|
1232
|
+
parseFloat(row.d7_pct) || 0,
|
|
1233
|
+
parseInt(row.d14_users),
|
|
1234
|
+
parseFloat(row.d14_pct) || 0,
|
|
1235
|
+
parseInt(row.d30_users),
|
|
1236
|
+
parseFloat(row.d30_pct) || 0,
|
|
1237
|
+
source === 'all' ? 'all' : row.user_source,
|
|
1238
|
+
];
|
|
1239
|
+
|
|
1240
|
+
csv += values.join(',') + '\n';
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
// Set headers for file download
|
|
1244
|
+
const filename = `cohort-retention-${period}-${new Date().toISOString().split('T')[0]}.csv`;
|
|
1245
|
+
res.setHeader('Content-Type', 'text/csv');
|
|
1246
|
+
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
|
1247
|
+
|
|
1248
|
+
console.log(`[Analytics] CSV export: ${cohortResponse.length} cohorts`);
|
|
1249
|
+
|
|
1250
|
+
return res.send(csv);
|
|
1251
|
+
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
console.error('[Analytics] Error exporting cohort CSV:', error);
|
|
1254
|
+
return res.status(500).json({
|
|
1255
|
+
success: false,
|
|
1256
|
+
error: 'Failed to export cohort retention CSV',
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
module.exports = router;
|
|
1262
|
+
|