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,407 @@
1
+ /**
2
+ * Notification Cache Service - Redis-backed high-performance notification layer
3
+ *
4
+ * Architecture:
5
+ * - Redis Sorted Set: notifications:{userId} → notification IDs sorted by timestamp
6
+ * - Redis Hash: notification:{id} → notification data
7
+ * - Redis Counter: unread:{userId} → instant unread count
8
+ * - TTL: 48 hours for cached notifications
9
+ *
10
+ * Gracefully falls back to PostgreSQL when Redis is unavailable.
11
+ */
12
+
13
+ const redisService = require('./redisService');
14
+
15
+ // Key prefixes
16
+ const KEYS = {
17
+ NOTIFICATIONS: (userId) => `notifications:${userId}`,
18
+ NOTIFICATION: (id) => `notification:${id}`,
19
+ UNREAD_COUNT: (userId) => `unread:${userId}`,
20
+ };
21
+
22
+ // Cache TTL (48 hours in seconds)
23
+ const CACHE_TTL = 48 * 60 * 60;
24
+
25
+ // Maximum notifications to cache per user
26
+ const MAX_CACHED_NOTIFICATIONS = 500;
27
+
28
+ class NotificationCacheService {
29
+ constructor() {
30
+ this.enabled = false;
31
+ }
32
+
33
+ /**
34
+ * Initialize the cache service
35
+ * Called during server startup
36
+ */
37
+ async initialize() {
38
+ this.enabled = await redisService.connect();
39
+ if (this.enabled) {
40
+ console.log('✅ [NotificationCache] Redis caching enabled');
41
+ } else {
42
+ console.log('⚠️ [NotificationCache] Running without Redis cache (PostgreSQL fallback)');
43
+ }
44
+ return this.enabled;
45
+ }
46
+
47
+ /**
48
+ * Check if caching is available
49
+ */
50
+ isEnabled() {
51
+ return this.enabled && redisService.isAvailable();
52
+ }
53
+
54
+ // ============================================
55
+ // WRITE OPERATIONS
56
+ // ============================================
57
+
58
+ /**
59
+ * Cache a new notification
60
+ * Called after writing to PostgreSQL
61
+ *
62
+ * @param {number} userId - User ID
63
+ * @param {Object} notification - Notification object with id, type, createdAt, etc.
64
+ */
65
+ async cacheNotification(userId, notification) {
66
+ if (!this.isEnabled()) return false;
67
+
68
+ try {
69
+ const notificationId = notification.id;
70
+ const timestamp = new Date(notification.createdAt).getTime();
71
+
72
+ // Use pipeline for atomic operations
73
+ const pipeline = redisService.pipeline();
74
+ if (!pipeline) return false;
75
+
76
+ // 1. Add to user's sorted set
77
+ pipeline.zadd(KEYS.NOTIFICATIONS(userId), timestamp, notificationId.toString());
78
+
79
+ // 2. Store notification data as hash
80
+ pipeline.hset(KEYS.NOTIFICATION(notificationId), {
81
+ id: notificationId.toString(),
82
+ type: notification.type || '',
83
+ read: notification.read ? '1' : '0',
84
+ messageId: notification.messageId?.toString() || '',
85
+ message: notification.message || '',
86
+ senderUsername: notification.senderUsername || '',
87
+ senderWallet: notification.senderWallet || '',
88
+ senderAvatar: notification.senderAvatar || '',
89
+ createdAt: notification.createdAt?.toISOString?.() || notification.createdAt || '',
90
+ // Store complex objects as JSON strings
91
+ gameInvite: notification.gameInvite ? JSON.stringify(notification.gameInvite) : '',
92
+ finalScore: notification.finalScore ? JSON.stringify(notification.finalScore) : '',
93
+ notificationData: notification.notificationData ? JSON.stringify(notification.notificationData) : '',
94
+ });
95
+
96
+ // 3. Set TTL on notification hash
97
+ pipeline.expire(KEYS.NOTIFICATION(notificationId), CACHE_TTL);
98
+
99
+ // 4. Increment unread counter if notification is unread
100
+ if (!notification.read) {
101
+ pipeline.incr(KEYS.UNREAD_COUNT(userId));
102
+ }
103
+
104
+ // 5. Trim sorted set to max size (keep most recent)
105
+ pipeline.zremrangebyrank(KEYS.NOTIFICATIONS(userId), 0, -(MAX_CACHED_NOTIFICATIONS + 1));
106
+
107
+ // 6. Set TTL on sorted set
108
+ pipeline.expire(KEYS.NOTIFICATIONS(userId), CACHE_TTL);
109
+
110
+ await pipeline.exec();
111
+
112
+ console.log(`[NotificationCache] ✅ Cached notification ${notificationId} for user ${userId}`);
113
+ return true;
114
+
115
+ } catch (error) {
116
+ console.error('[NotificationCache] Error caching notification:', error.message);
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Bulk cache notifications (for warming cache from PostgreSQL)
123
+ *
124
+ * @param {number} userId - User ID
125
+ * @param {Object[]} notifications - Array of notification objects
126
+ */
127
+ async bulkCacheNotifications(userId, notifications) {
128
+ if (!this.isEnabled() || notifications.length === 0) return false;
129
+
130
+ try {
131
+ const pipeline = redisService.pipeline();
132
+ if (!pipeline) return false;
133
+
134
+ let unreadCount = 0;
135
+
136
+ for (const notification of notifications) {
137
+ const notificationId = notification.id;
138
+ const timestamp = new Date(notification.createdAt).getTime();
139
+
140
+ // Add to sorted set
141
+ pipeline.zadd(KEYS.NOTIFICATIONS(userId), timestamp, notificationId.toString());
142
+
143
+ // Store notification data
144
+ pipeline.hset(KEYS.NOTIFICATION(notificationId), {
145
+ id: notificationId.toString(),
146
+ type: notification.type || '',
147
+ read: notification.read ? '1' : '0',
148
+ messageId: notification.messageId?.toString() || '',
149
+ message: notification.message || '',
150
+ senderUsername: notification.senderUsername || '',
151
+ senderWallet: notification.senderWallet || '',
152
+ senderAvatar: notification.senderAvatar || '',
153
+ createdAt: notification.createdAt?.toISOString?.() || notification.createdAt || '',
154
+ gameInvite: notification.gameInvite ? JSON.stringify(notification.gameInvite) : '',
155
+ finalScore: notification.finalScore ? JSON.stringify(notification.finalScore) : '',
156
+ notificationData: notification.notificationData ? JSON.stringify(notification.notificationData) : '',
157
+ });
158
+
159
+ pipeline.expire(KEYS.NOTIFICATION(notificationId), CACHE_TTL);
160
+
161
+ if (!notification.read) {
162
+ unreadCount++;
163
+ }
164
+ }
165
+
166
+ // Set unread count
167
+ pipeline.set(KEYS.UNREAD_COUNT(userId), unreadCount.toString());
168
+
169
+ // Trim and set TTL
170
+ pipeline.zremrangebyrank(KEYS.NOTIFICATIONS(userId), 0, -(MAX_CACHED_NOTIFICATIONS + 1));
171
+ pipeline.expire(KEYS.NOTIFICATIONS(userId), CACHE_TTL);
172
+
173
+ await pipeline.exec();
174
+
175
+ console.log(`[NotificationCache] ✅ Bulk cached ${notifications.length} notifications for user ${userId}`);
176
+ return true;
177
+
178
+ } catch (error) {
179
+ console.error('[NotificationCache] Error bulk caching:', error.message);
180
+ return false;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Mark notification(s) as read
186
+ *
187
+ * @param {number} userId - User ID
188
+ * @param {number[]} notificationIds - Array of notification IDs
189
+ */
190
+ async markAsRead(userId, notificationIds) {
191
+ if (!this.isEnabled()) return false;
192
+
193
+ try {
194
+ const pipeline = redisService.pipeline();
195
+ if (!pipeline) return false;
196
+
197
+ let markedCount = 0;
198
+
199
+ for (const notificationId of notificationIds) {
200
+ // Check current read status
201
+ const notification = await redisService.hgetall(KEYS.NOTIFICATION(notificationId));
202
+ if (notification && notification.read === '0') {
203
+ pipeline.hset(KEYS.NOTIFICATION(notificationId), { read: '1' });
204
+ markedCount++;
205
+ }
206
+ }
207
+
208
+ // Decrement unread count
209
+ if (markedCount > 0) {
210
+ // Use script to safely decrement by N without going below 0
211
+ const currentCount = await redisService.get(KEYS.UNREAD_COUNT(userId));
212
+ const newCount = Math.max(0, (parseInt(currentCount) || 0) - markedCount);
213
+ pipeline.set(KEYS.UNREAD_COUNT(userId), newCount.toString());
214
+ }
215
+
216
+ await pipeline.exec();
217
+ return true;
218
+
219
+ } catch (error) {
220
+ console.error('[NotificationCache] Error marking as read:', error.message);
221
+ return false;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Invalidate user's notification cache
227
+ * Call when data might be stale (e.g., bulk operations)
228
+ */
229
+ async invalidateUserCache(userId) {
230
+ if (!this.isEnabled()) return false;
231
+
232
+ try {
233
+ // Get all notification IDs for user
234
+ const notificationIds = await redisService.zrevrange(KEYS.NOTIFICATIONS(userId), 0, -1);
235
+
236
+ if (notificationIds && notificationIds.length > 0) {
237
+ const keysToDelete = notificationIds.map(id => KEYS.NOTIFICATION(id));
238
+ keysToDelete.push(KEYS.NOTIFICATIONS(userId));
239
+ keysToDelete.push(KEYS.UNREAD_COUNT(userId));
240
+ await redisService.del(...keysToDelete);
241
+ }
242
+
243
+ console.log(`[NotificationCache] Invalidated cache for user ${userId}`);
244
+ return true;
245
+
246
+ } catch (error) {
247
+ console.error('[NotificationCache] Error invalidating cache:', error.message);
248
+ return false;
249
+ }
250
+ }
251
+
252
+ // ============================================
253
+ // READ OPERATIONS
254
+ // ============================================
255
+
256
+ /**
257
+ * Get paginated notifications from cache
258
+ * Uses cursor-based pagination with timestamps
259
+ *
260
+ * @param {number} userId - User ID
261
+ * @param {Object} options - Pagination options
262
+ * @param {number} options.limit - Number of notifications to fetch (default: 10)
263
+ * @param {number} options.cursor - Timestamp cursor (fetch items before this timestamp)
264
+ * @returns {Object|null} - { notifications, nextCursor, hasMore } or null if cache miss
265
+ */
266
+ async getNotifications(userId, { limit = 10, cursor = null } = {}) {
267
+ if (!this.isEnabled()) return null;
268
+
269
+ try {
270
+ // Check if user has cached notifications
271
+ const exists = await redisService.exists(KEYS.NOTIFICATIONS(userId));
272
+ if (!exists) {
273
+ console.log(`[NotificationCache] Cache miss for user ${userId}`);
274
+ return null; // Cache miss - caller should fetch from PostgreSQL and warm cache
275
+ }
276
+
277
+ // Get notification IDs from sorted set
278
+ let notificationIds;
279
+ if (cursor) {
280
+ // Cursor-based: get notifications before the cursor timestamp
281
+ // Use '(' prefix for exclusive max
282
+ notificationIds = await redisService.zrevrangebyscore(
283
+ KEYS.NOTIFICATIONS(userId),
284
+ `(${cursor}`, // Exclusive - don't include the cursor item
285
+ '-inf',
286
+ 0,
287
+ limit + 1 // Fetch one extra to check hasMore
288
+ );
289
+ } else {
290
+ // No cursor: get most recent notifications
291
+ // ZREVRANGE with indices 0 to limit returns limit+1 items (indices are inclusive)
292
+ notificationIds = await redisService.zrevrange(
293
+ KEYS.NOTIFICATIONS(userId),
294
+ 0,
295
+ limit // Returns limit+1 items (indices 0 through limit, inclusive)
296
+ );
297
+ }
298
+
299
+ if (!notificationIds || notificationIds.length === 0) {
300
+ return { notifications: [], nextCursor: null, hasMore: false };
301
+ }
302
+
303
+ // Check if there are more results
304
+ const hasMore = notificationIds.length > limit;
305
+ if (hasMore) {
306
+ notificationIds = notificationIds.slice(0, limit);
307
+ }
308
+
309
+ // Batch fetch notification data
310
+ const notificationKeys = notificationIds.map(id => KEYS.NOTIFICATION(id));
311
+ const notificationsData = await redisService.hmgetall(notificationKeys);
312
+
313
+ if (!notificationsData) {
314
+ return null; // Cache miss on individual notifications
315
+ }
316
+
317
+ // Parse and format notifications
318
+ const notifications = notificationsData
319
+ .filter(data => data !== null)
320
+ .map(data => this._parseNotificationFromCache(data));
321
+
322
+ // Get next cursor (timestamp of last item)
323
+ let nextCursor = null;
324
+ if (hasMore && notifications.length > 0) {
325
+ const lastNotification = notifications[notifications.length - 1];
326
+ nextCursor = new Date(lastNotification.createdAt).getTime();
327
+ }
328
+
329
+ return {
330
+ notifications,
331
+ nextCursor,
332
+ hasMore,
333
+ };
334
+
335
+ } catch (error) {
336
+ console.error('[NotificationCache] Error getting notifications:', error.message);
337
+ return null;
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Get unread count from cache
343
+ *
344
+ * @param {number} userId - User ID
345
+ * @returns {number|null} - Unread count or null if cache miss
346
+ */
347
+ async getUnreadCount(userId) {
348
+ if (!this.isEnabled()) return null;
349
+
350
+ try {
351
+ const count = await redisService.get(KEYS.UNREAD_COUNT(userId));
352
+ if (count === null) return null; // Cache miss
353
+ return parseInt(count) || 0;
354
+ } catch (error) {
355
+ console.error('[NotificationCache] Error getting unread count:', error.message);
356
+ return null;
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Set unread count (used when warming cache)
362
+ */
363
+ async setUnreadCount(userId, count) {
364
+ if (!this.isEnabled()) return false;
365
+ return redisService.set(KEYS.UNREAD_COUNT(userId), count.toString(), CACHE_TTL);
366
+ }
367
+
368
+ // ============================================
369
+ // HELPERS
370
+ // ============================================
371
+
372
+ /**
373
+ * Parse notification data from Redis hash format
374
+ */
375
+ _parseNotificationFromCache(data) {
376
+ return {
377
+ id: parseInt(data.id),
378
+ type: data.type,
379
+ read: data.read === '1',
380
+ messageId: data.messageId ? parseInt(data.messageId) : null,
381
+ message: data.message || '',
382
+ senderUsername: data.senderUsername || 'Unknown',
383
+ senderWallet: data.senderWallet || '',
384
+ senderAvatar: data.senderAvatar || null,
385
+ createdAt: data.createdAt || null,
386
+ gameInvite: data.gameInvite ? this._safeJsonParse(data.gameInvite) : undefined,
387
+ finalScore: data.finalScore ? this._safeJsonParse(data.finalScore) : undefined,
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Safely parse JSON (returns null on error)
393
+ */
394
+ _safeJsonParse(str) {
395
+ try {
396
+ return JSON.parse(str);
397
+ } catch {
398
+ return null;
399
+ }
400
+ }
401
+ }
402
+
403
+ // Singleton instance
404
+ const notificationCacheService = new NotificationCacheService();
405
+
406
+ module.exports = notificationCacheService;
407
+