dubs-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/.claude/settings.local.json +280 -0
  2. package/CLAUDE.md +46 -0
  3. package/CONNECT4_PRODUCTION_DEPLOY.md +155 -0
  4. package/CURRENT_SESSION.md +171 -0
  5. package/CURRENT_SESSION_DRAW.md +516 -0
  6. package/MARCH_MADNESS_SURVIVOR.md +254 -0
  7. package/PANDA.md +166 -0
  8. package/Procfile +4 -0
  9. package/README.md +476 -0
  10. package/controllers/livescoresController.js +376 -0
  11. package/controllers/pickemController.js +554 -0
  12. package/controllers/survivorAdminController.js +887 -0
  13. package/controllers/survivorController.js +623 -0
  14. package/cron/oracleMonitor.js +77 -0
  15. package/cron/pickemOracleMonitor.js +73 -0
  16. package/data/jackpot-history.json +952 -0
  17. package/data/ncaaTeams.js +406 -0
  18. package/documentation/API_SECURITY_GUIDE.md +327 -0
  19. package/documentation/ARCADE_API.md +593 -0
  20. package/documentation/ARCADE_IMPLEMENTATION_SUMMARY.md +399 -0
  21. package/documentation/ARCADE_QUICKSTART.md +242 -0
  22. package/documentation/AUTOMATIC_MODE_ORACLE.md +321 -0
  23. package/documentation/BUG_FIX_COHORT_DATE_DISPLAY.md +171 -0
  24. package/documentation/CLAIM_MIGRATION_INSTRUCTIONS.md +52 -0
  25. package/documentation/CLAIM_STATUS_FIX.md +67 -0
  26. package/documentation/CLI_TOOL_GUIDE.md +372 -0
  27. package/documentation/COHORT_RETENTION_ANALYSIS.md +295 -0
  28. package/documentation/COHORT_RETENTION_IMPLEMENTATION_COMPLETE.md +461 -0
  29. package/documentation/COHORT_RETENTION_SUMMARY.md +204 -0
  30. package/documentation/COMPLETE_PROJECT_SUMMARY.md +490 -0
  31. package/documentation/DATABASE_QUERIES.md +269 -0
  32. package/documentation/DATABASE_RETENTION_POLICY.md +390 -0
  33. package/documentation/DATABASE_SETUP_GUIDE.md +361 -0
  34. package/documentation/DATABASE_SETUP_SUMMARY.md +247 -0
  35. package/documentation/DEMO_API_CURL_COMMANDS.md +656 -0
  36. package/documentation/DEPLOYMENT_SUMMARY.txt +100 -0
  37. package/documentation/DUPLICATE_NOTIFICATIONS_FIXED.md +201 -0
  38. package/documentation/EXCHANGE_RATES_INTEGRATION.md +371 -0
  39. package/documentation/FINAL_API_PROTECTION_TABLE.md +175 -0
  40. package/documentation/GAME_START_NOTIFICATIONS_DEPLOYMENT.md +256 -0
  41. package/documentation/GAME_START_NOTIFICATIONS_INTEGRATION.md +275 -0
  42. package/documentation/HEROKU_DEPLOYMENT.md +134 -0
  43. package/documentation/HEROKU_SCHEDULER_SETUP.md +271 -0
  44. package/documentation/JACKPOT_API.md +521 -0
  45. package/documentation/JACKPOT_DEPLOYMENT_GUIDE.md +362 -0
  46. package/documentation/JWT_IMPLEMENTATION_SUMMARY.md +373 -0
  47. package/documentation/JWT_QUICK_SETUP.md +268 -0
  48. package/documentation/JWT_TESTING_GUIDE.md +404 -0
  49. package/documentation/KEEPER_RECOVERY_GUIDE.md +381 -0
  50. package/documentation/KEEPER_SETUP.md +206 -0
  51. package/documentation/KEEPER_STATE_MACHINE.md +423 -0
  52. package/documentation/LATEST_PRODUCTION_SETUP.md +387 -0
  53. package/documentation/LOCAL_VOTING_TEST.md +279 -0
  54. package/documentation/ORACLE_FIXES_SUMMARY.md +188 -0
  55. package/documentation/ORACLE_POSTGRESQL_UPDATE.md +202 -0
  56. package/documentation/PAYMENT_DEPLOYMENT.md +209 -0
  57. package/documentation/PNL_TRACKING_SETUP.md +189 -0
  58. package/documentation/PREVENTING_LOCKUP_ERRORS.md +472 -0
  59. package/documentation/PRODUCTION_READY_SUMMARY.md +227 -0
  60. package/documentation/PUBLIC_VS_PRIVATE_ENDPOINTS.md +278 -0
  61. package/documentation/QUICK_AUTH_SETUP.md +99 -0
  62. package/documentation/QUICK_DEPLOY.md +224 -0
  63. package/documentation/QUICK_FIX.md +114 -0
  64. package/documentation/QUICK_START.md +152 -0
  65. package/documentation/REFEREE_MODE_GUIDE.md +392 -0
  66. package/documentation/RETENTION_CORE_ACTION_UPDATE.md +313 -0
  67. package/documentation/RETENTION_UPDATE_SUMMARY.md +108 -0
  68. package/documentation/RUN_MIGRATION_NOW.md +39 -0
  69. package/documentation/SCRIPTS_UPDATE_SUMMARY.md +251 -0
  70. package/documentation/SETUP_GUIDE.md +184 -0
  71. package/documentation/STATE_MACHINE_IMPLEMENTATION.md +250 -0
  72. package/documentation/TELEGRAM_NOTIFICATIONS_DIAGNOSIS.md +361 -0
  73. package/documentation/UNIFIED_ARCHITECTURE.md +231 -0
  74. package/documentation/VOTING_DEPLOYMENT_SUMMARY.md +392 -0
  75. package/documentation/WEBSOCKET_ARCHITECTURE.md +881 -0
  76. package/documentation/WHAT_WE_BUILT_TODAY.md +369 -0
  77. package/documentation/latest/LATEST_PRODUCTION_SETUP.md +865 -0
  78. package/ecosystem.config.js +65 -0
  79. package/env.template +125 -0
  80. package/middleware/apiKeyAuth.js +136 -0
  81. package/middleware/authenticate.js +214 -0
  82. package/middleware/developerUserAuth.js +76 -0
  83. package/middleware/socketAuth.js +69 -0
  84. package/package.json +49 -0
  85. package/postman/Dubs-API-v1-With-Voting.postman_collection.json +555 -0
  86. package/postman/Dubs-API-v1.postman_collection.json +205 -0
  87. package/postman/Dubs_Developer_API.postman_collection.json +662 -0
  88. package/postman/QUICKSTART.md +118 -0
  89. package/postman/QUICK_REFERENCE.md +246 -0
  90. package/postman/README.md +71 -0
  91. package/postman/VOTING_API_GUIDE.md +426 -0
  92. package/refactor/Animations.md +148 -0
  93. package/refactor/Chat.md +252 -0
  94. package/routes/actionsRoutes.js +699 -0
  95. package/routes/adminRoutes.js +370 -0
  96. package/routes/analyticsRoutes.js +1262 -0
  97. package/routes/arcadeRoutes.js +557 -0
  98. package/routes/authRoutes.js +2310 -0
  99. package/routes/avatarRoutes.js +85 -0
  100. package/routes/botRoutes.js +211 -0
  101. package/routes/chatRoutes.js +377 -0
  102. package/routes/cryptoPriceRoutes.js +105 -0
  103. package/routes/developerRoutes.js +4201 -0
  104. package/routes/deviceRoutes.js +214 -0
  105. package/routes/dmRoutes.js +167 -0
  106. package/routes/esportsRoutes.js +806 -0
  107. package/routes/exchangeRateRoutes.js +233 -0
  108. package/routes/gamesRoutes.js +3028 -0
  109. package/routes/jackpotRoutes.js +754 -0
  110. package/routes/keeperMonitoringRoutes.js +156 -0
  111. package/routes/keeperWebhookRoutes.js +466 -0
  112. package/routes/livescoresRoutes.js +31 -0
  113. package/routes/pickemAdminRoutes.js +199 -0
  114. package/routes/pickemRoutes.js +231 -0
  115. package/routes/playerStatsRoutes.js +147 -0
  116. package/routes/portfolioRoutes.js +217 -0
  117. package/routes/promoRoutes.js +418 -0
  118. package/routes/referralEarningsRoutes.js +392 -0
  119. package/routes/socialRoutes.js +459 -0
  120. package/routes/sportsRoutes.js +1271 -0
  121. package/routes/survivorAdminRoutes.js +345 -0
  122. package/routes/survivorRoutes.js +756 -0
  123. package/routes/uploadRoutes.js +256 -0
  124. package/routes/userProfileRoutes.js +244 -0
  125. package/routes/whatsNewRoutes.js +331 -0
  126. package/scripts/.claude/settings.local.json +15 -0
  127. package/scripts/README.md +170 -0
  128. package/scripts/RESTART_EVERYTHING.sh +104 -0
  129. package/scripts/add-claim-columns.sql +48 -0
  130. package/scripts/add-crypto-prices-cache.sql +27 -0
  131. package/scripts/add-exchange-rates-cache.sql +40 -0
  132. package/scripts/add-game-invite-column.sql +23 -0
  133. package/scripts/add-game-invite-notification.sql +33 -0
  134. package/scripts/add-game-invite-telegram-pref.sql +16 -0
  135. package/scripts/add-game-joined-notification.sql +16 -0
  136. package/scripts/add-game-joined-pref.js +40 -0
  137. package/scripts/add-game-joined-preference.sql +6 -0
  138. package/scripts/add-game-start-notifications.sql +41 -0
  139. package/scripts/add-notification-flags-to-games.sql +55 -0
  140. package/scripts/add-pending-game-dismissals.sql +19 -0
  141. package/scripts/add-preferred-currency.sql +34 -0
  142. package/scripts/add-winner-columns.js +61 -0
  143. package/scripts/add_mention_system.sql +53 -0
  144. package/scripts/add_payment_system.sql +96 -0
  145. package/scripts/add_sports_event_id_column.sql +22 -0
  146. package/scripts/analyze-cohort-data-heroku.js +276 -0
  147. package/scripts/analyze-cohort-data.js +295 -0
  148. package/scripts/analyze-prod-cohorts.sh +10 -0
  149. package/scripts/backfill-matchup-images.js +245 -0
  150. package/scripts/backfill-missing-signatures.js +175 -0
  151. package/scripts/backfill-referral-earnings.js +202 -0
  152. package/scripts/check-chat-schema.js +130 -0
  153. package/scripts/check-db.sh +14 -0
  154. package/scripts/check_oracle_in_game.js +54 -0
  155. package/scripts/cleanup-database.js +193 -0
  156. package/scripts/clear-notification-cache.js +85 -0
  157. package/scripts/convert-mnemonic.js +50 -0
  158. package/scripts/create-users-table.sql +44 -0
  159. package/scripts/debug-cohort-counts.js +248 -0
  160. package/scripts/debug-winner-calc.js +84 -0
  161. package/scripts/deploy-payment-system.sh +118 -0
  162. package/scripts/deploy-to-heroku.sh +63 -0
  163. package/scripts/diagnose-locked-round.js +143 -0
  164. package/scripts/dubs-cli.js +720 -0
  165. package/scripts/dump-account.js +65 -0
  166. package/scripts/find-vrf-offset.js +48 -0
  167. package/scripts/fix-chat-notifications-constraint.sql +122 -0
  168. package/scripts/fix-claim-columns.js +124 -0
  169. package/scripts/fix-constraint-now.js +44 -0
  170. package/scripts/fix-lock-timestamps.js +96 -0
  171. package/scripts/fix-locked-round.sh +126 -0
  172. package/scripts/fix-missing-badges.sql +91 -0
  173. package/scripts/fix-payment-notifications.sql +41 -0
  174. package/scripts/force-new-round.js +55 -0
  175. package/scripts/force-resolve-and-claim.js +278 -0
  176. package/scripts/important/README.md +115 -0
  177. package/scripts/important/authority-force-lock.js +197 -0
  178. package/scripts/important/authority-resolve-game.js +267 -0
  179. package/scripts/important/check-game-status.js +373 -0
  180. package/scripts/important/list-pending-games-by-version.js +270 -0
  181. package/scripts/important/reconcile-v1-v2-payouts.js +270 -0
  182. package/scripts/initialize-jackpot.js +111 -0
  183. package/scripts/jackpot/.claude/settings.local.json +10 -0
  184. package/scripts/jackpot/force-reset.js +84 -0
  185. package/scripts/jackpot/initialize-mainnet.js +100 -0
  186. package/scripts/jackpot/keeper.js +742 -0
  187. package/scripts/jackpot/status.js +107 -0
  188. package/scripts/jackpot/update-round-duration.js +143 -0
  189. package/scripts/keeper-bot.js +112 -0
  190. package/scripts/list-pending-games.js +131 -0
  191. package/scripts/migrate-chat-v2.js +127 -0
  192. package/scripts/migrate-chat-winners.js +84 -0
  193. package/scripts/migrate-chat.sh +17 -0
  194. package/scripts/migrate-game-invite.js +83 -0
  195. package/scripts/migrate-heroku-game-notifications.sh +159 -0
  196. package/scripts/migrations/001_analytics_tables.sql +422 -0
  197. package/scripts/migrations/002_add_matchup_image_url.sql +14 -0
  198. package/scripts/migrations/003_referral_earnings.sql +208 -0
  199. package/scripts/migrations/004_add_whats_new_notification_type.sql +62 -0
  200. package/scripts/migrations/005_add_connect4_your_turn_notification.sql +61 -0
  201. package/scripts/migrations/005_push_notifications.sql +55 -0
  202. package/scripts/migrations/006_add_draw_team_players.sql +28 -0
  203. package/scripts/migrations/006_add_game_cancelled_notification.sql +62 -0
  204. package/scripts/migrations/007_add_gif_url.sql +8 -0
  205. package/scripts/migrations/008_add_connect4_columns.sql +139 -0
  206. package/scripts/migrations/008_add_pool_tracking.sql +22 -0
  207. package/scripts/migrations/009_create_survivor_pool_tables.sql +174 -0
  208. package/scripts/migrations/010_add_survivor_pool_outcome.sql +28 -0
  209. package/scripts/migrations/011_create_developer_tables.sql +67 -0
  210. package/scripts/migrations/011_fix_keeper_tables.sql +85 -0
  211. package/scripts/migrations/012_create_developer_webhooks.sql +31 -0
  212. package/scripts/migrations/013_add_network_mode.sql +18 -0
  213. package/scripts/migrations/014_create_developer_app_users.sql +19 -0
  214. package/scripts/migrations/015_add_ui_config.sql +4 -0
  215. package/scripts/migrations/016_add_resolution_secret.sql +4 -0
  216. package/scripts/migrations/017_add_external_game_id.sql +3 -0
  217. package/scripts/migrations/018_create_pickem_tables.sql +115 -0
  218. package/scripts/migrations/019_expo_push_tokens.sql +19 -0
  219. package/scripts/migrations/create_whats_new_tables.sql +88 -0
  220. package/scripts/migrations/drop_live_games_tables.sql +34 -0
  221. package/scripts/open-jackpot-round.js +85 -0
  222. package/scripts/purge-all-data.sh +329 -0
  223. package/scripts/purge-all-data.sql +142 -0
  224. package/scripts/purge-heroku-data.sh +149 -0
  225. package/scripts/purge-heroku-data.sql +62 -0
  226. package/scripts/rebuild-heroku-database.sh +113 -0
  227. package/scripts/recover-funds.js +357 -0
  228. package/scripts/regenerate-epl-images.js +278 -0
  229. package/scripts/resize-s3-matchup-images.js +374 -0
  230. package/scripts/resolve-direct.js +88 -0
  231. package/scripts/resolve-mock-game.js +124 -0
  232. package/scripts/resolve-pickem-game.js +55 -0
  233. package/scripts/resolve-round-manual.js +83 -0
  234. package/scripts/resolve-stuck-game.js +382 -0
  235. package/scripts/resolve-stuck-round.js +42 -0
  236. package/scripts/run-connect4-migration.sh +16 -0
  237. package/scripts/run-mention-migration.sh +32 -0
  238. package/scripts/run-payment-migration.sh +51 -0
  239. package/scripts/run-preferred-currency-migration.sh +31 -0
  240. package/scripts/run-referral-earnings-migration.sh +32 -0
  241. package/scripts/run-survivor-outcome-migration.sh +16 -0
  242. package/scripts/seed-test-users.js +346 -0
  243. package/scripts/setup-auth-tables.js +78 -0
  244. package/scripts/setup-complete-database.sql +992 -0
  245. package/scripts/setup-database-fresh.sh +359 -0
  246. package/scripts/setup-heroku-keeper.sh +48 -0
  247. package/scripts/setup-keeper-database.js +83 -0
  248. package/scripts/setup-keeper-state-db.sql +110 -0
  249. package/scripts/setup-oracle.sh +39 -0
  250. package/scripts/setup-pnl-tracking.js +111 -0
  251. package/scripts/start-devnet.sh +14 -0
  252. package/scripts/test-arcade-devnet.sh +160 -0
  253. package/scripts/test-arcade-match.sh +109 -0
  254. package/scripts/test-automatic-mode.sh +239 -0
  255. package/scripts/test-connect4-cancel-claim.js +370 -0
  256. package/scripts/test-connect4-e2e.js +369 -0
  257. package/scripts/test-connect4-resolve.js +369 -0
  258. package/scripts/test-game-state-endpoint.js +136 -0
  259. package/scripts/test-invite-notification.js +86 -0
  260. package/scripts/test-jackpot-api.sh +71 -0
  261. package/scripts/test-poll-confirmation.js +267 -0
  262. package/scripts/test-resolve-game.js +271 -0
  263. package/scripts/test-resolve-signature.js +223 -0
  264. package/scripts/test-signature-preservation.js +124 -0
  265. package/scripts/test-state-machine.js +291 -0
  266. package/scripts/test-webhook-receiver.js +60 -0
  267. package/scripts/update-notification-constraint.js +52 -0
  268. package/scripts/verify-account-layout.js +145 -0
  269. package/scripts/verify-winner-algorithm.js +278 -0
  270. package/server.js +5259 -0
  271. package/services/arcadeMatchService.js +763 -0
  272. package/services/automaticGameOracle.js +1596 -0
  273. package/services/chatService.js +1612 -0
  274. package/services/connect4GameService.js +1049 -0
  275. package/services/connect4NotificationService.js +374 -0
  276. package/services/cryptoPriceService.js +223 -0
  277. package/services/customGameResolver.js +260 -0
  278. package/services/db.js +79 -0
  279. package/services/directMessageService.js +389 -0
  280. package/services/discordNotifications.js +160 -0
  281. package/services/exchangeRateService.js +289 -0
  282. package/services/expoPushService.js +314 -0
  283. package/services/gamesCacheService.js +539 -0
  284. package/services/jackpotHistory.js +331 -0
  285. package/services/jackpotService.js +856 -0
  286. package/services/keeperStateService.js +355 -0
  287. package/services/matchupImageService.js +591 -0
  288. package/services/notificationCacheService.js +407 -0
  289. package/services/pickemOracle.js +440 -0
  290. package/services/playerStatsService.js +389 -0
  291. package/services/portfolioService.js +555 -0
  292. package/services/promoService.js +757 -0
  293. package/services/promoTreasuryService.js +239 -0
  294. package/services/pushNotifications.js +353 -0
  295. package/services/redisService.js +422 -0
  296. package/services/referralEarningsService.js +728 -0
  297. package/services/s3Service.js +396 -0
  298. package/services/socialService.js +1202 -0
  299. package/services/survivorOracle.js +469 -0
  300. package/services/survivorSimulator.js +475 -0
  301. package/services/telegramNotifications.js +461 -0
  302. package/services/userProfileStatsService.js +1185 -0
  303. package/services/whatsNewService.js +388 -0
  304. package/utils/urlHelper.js +95 -0
@@ -0,0 +1,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
+