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