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,1612 @@
1
+ /**
2
+ * 💬 Degen Chat Service (Postgres + Redis)
3
+ *
4
+ * Real-time chat for jackpot players - wallet-gated TRON vibes!
5
+ *
6
+ * Performance: Uses Redis for high-speed notification caching with
7
+ * PostgreSQL as the source of truth.
8
+ */
9
+
10
+ const { pool } = require('./db'); // Shared database pool
11
+ const { forwardChatNotification } = require('./telegramNotifications');
12
+ const notificationCacheService = require('./notificationCacheService');
13
+
14
+ class ChatService {
15
+ constructor() {
16
+ // Use shared pool from services/db.js
17
+ this.pool = pool;
18
+
19
+ // Note: initializeTable must be called from server.js before server starts
20
+ this.initialized = false;
21
+ }
22
+
23
+ /**
24
+ * Cache a notification to Redis after PostgreSQL insert
25
+ * Non-blocking - errors are logged but don't fail the operation
26
+ */
27
+ async _cacheNotificationAsync(userId, notification) {
28
+ try {
29
+ await notificationCacheService.cacheNotification(userId, notification);
30
+ } catch (error) {
31
+ console.error('[ChatService] Failed to cache notification:', error.message);
32
+ // Don't throw - Redis is a cache, PostgreSQL is source of truth
33
+ }
34
+ }
35
+
36
+ async initializeTable() {
37
+ try {
38
+ // NOTE: This only CREATES tables if they don't exist
39
+ // It does NOT drop existing tables to preserve data
40
+ // If you need to migrate schema, use a separate migration script
41
+
42
+ // Create new v2 tables (IF NOT EXISTS - preserves data)
43
+ await this.pool.query(`
44
+ -- Main chat messages table (enhanced)
45
+ CREATE TABLE IF NOT EXISTS chat_messages (
46
+ id SERIAL PRIMARY KEY,
47
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
48
+ wallet_address VARCHAR(100) NOT NULL,
49
+ username VARCHAR(50) NOT NULL,
50
+ avatar TEXT,
51
+ message TEXT NOT NULL,
52
+ reply_to_id INTEGER REFERENCES chat_messages(id) ON DELETE SET NULL,
53
+ is_winner_announcement BOOLEAN DEFAULT FALSE,
54
+ win_amount NUMERIC(20, 9),
55
+ round_id INTEGER,
56
+ game_invite_metadata JSONB,
57
+ edited BOOLEAN DEFAULT FALSE,
58
+ edited_at TIMESTAMP,
59
+ deleted BOOLEAN DEFAULT FALSE,
60
+ deleted_at TIMESTAMP,
61
+ timestamp TIMESTAMP DEFAULT NOW(),
62
+ created_at TIMESTAMP DEFAULT NOW()
63
+ );
64
+
65
+ -- Message reactions (for future)
66
+ CREATE TABLE IF NOT EXISTS chat_reactions (
67
+ id SERIAL PRIMARY KEY,
68
+ message_id INTEGER REFERENCES chat_messages(id) ON DELETE CASCADE,
69
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
70
+ wallet_address VARCHAR(100) NOT NULL,
71
+ reaction VARCHAR(20) NOT NULL,
72
+ created_at TIMESTAMP DEFAULT NOW(),
73
+ UNIQUE(message_id, user_id, reaction)
74
+ );
75
+
76
+ -- User relationships (friends/blocks)
77
+ CREATE TABLE IF NOT EXISTS user_relationships (
78
+ id SERIAL PRIMARY KEY,
79
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
80
+ target_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
81
+ relationship_type VARCHAR(20) NOT NULL CHECK (relationship_type IN ('friend', 'block')),
82
+ created_at TIMESTAMP DEFAULT NOW(),
83
+ UNIQUE(user_id, target_user_id)
84
+ );
85
+
86
+ -- Chat notifications
87
+ CREATE TABLE IF NOT EXISTS chat_notifications (
88
+ id SERIAL PRIMARY KEY,
89
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
90
+ message_id INTEGER REFERENCES chat_messages(id) ON DELETE CASCADE,
91
+ sender_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
92
+ notification_type VARCHAR(30) NOT NULL CHECK (notification_type IN ('reply', 'mention', 'friend_message', 'reaction', 'friend_request', 'friend_request_accepted', 'friend_request_declined', 'referral', 'game_joined', 'game_invite', 'game_starting_soon', 'game_starting_now', 'game_won', 'game_lost', 'payment_received', 'payment_sent', 'dm', 'whats_new')),
93
+ notification_data JSONB,
94
+ read BOOLEAN DEFAULT FALSE,
95
+ created_at TIMESTAMP DEFAULT NOW()
96
+ );
97
+
98
+ -- Indexes for performance
99
+ CREATE INDEX IF NOT EXISTS idx_chat_timestamp ON chat_messages(timestamp DESC);
100
+ CREATE INDEX IF NOT EXISTS idx_chat_wallet ON chat_messages(wallet_address);
101
+ CREATE INDEX IF NOT EXISTS idx_chat_user_id ON chat_messages(user_id);
102
+ CREATE INDEX IF NOT EXISTS idx_chat_reply_to ON chat_messages(reply_to_id);
103
+ CREATE INDEX IF NOT EXISTS idx_chat_deleted ON chat_messages(deleted) WHERE deleted = false;
104
+ CREATE INDEX IF NOT EXISTS idx_reactions_message ON chat_reactions(message_id);
105
+ CREATE INDEX IF NOT EXISTS idx_relationships_user ON user_relationships(user_id);
106
+ CREATE INDEX IF NOT EXISTS idx_relationships_type ON user_relationships(relationship_type);
107
+ CREATE INDEX IF NOT EXISTS idx_notifications_user ON chat_notifications(user_id);
108
+ CREATE INDEX IF NOT EXISTS idx_notifications_read ON chat_notifications(read) WHERE read = false;
109
+ `);
110
+
111
+ // Add sender_user_id column if it doesn't exist (safe migration)
112
+ try {
113
+ await this.pool.query(`
114
+ DO $$
115
+ BEGIN
116
+ IF NOT EXISTS (
117
+ SELECT 1 FROM information_schema.columns
118
+ WHERE table_name = 'chat_notifications' AND column_name = 'sender_user_id'
119
+ ) THEN
120
+ ALTER TABLE chat_notifications
121
+ ADD COLUMN sender_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
122
+
123
+ RAISE NOTICE 'Added sender_user_id column to chat_notifications';
124
+ END IF;
125
+ END $$;
126
+ `);
127
+ console.log('✅ Chat notifications schema updated');
128
+ } catch (columnError) {
129
+ console.log('⚠️ Chat column migration error:', columnError.message);
130
+ }
131
+
132
+ // NOTE: Notification type constraint is now managed by migration file:
133
+ // scripts/migrations/004_add_whats_new_notification_type.sql
134
+ // Do not update the constraint here - run the migration instead.
135
+
136
+ // Add game_invite_metadata column if it doesn't exist (for existing databases)
137
+ try {
138
+ await this.pool.query(`
139
+ DO $$
140
+ BEGIN
141
+ IF NOT EXISTS (
142
+ SELECT 1 FROM information_schema.columns
143
+ WHERE table_name = 'chat_messages' AND column_name = 'game_invite_metadata'
144
+ ) THEN
145
+ ALTER TABLE chat_messages ADD COLUMN game_invite_metadata JSONB;
146
+ CREATE INDEX idx_chat_messages_game_invite
147
+ ON chat_messages ((game_invite_metadata->>'gameId'))
148
+ WHERE game_invite_metadata IS NOT NULL;
149
+ END IF;
150
+ END $$;
151
+ `);
152
+ console.log('✅ Game invite metadata column ready');
153
+ } catch (migrateError) {
154
+ console.log('⚠️ Game invite migration error:', migrateError.message);
155
+ }
156
+
157
+ // Add pnl_share_metadata column if it doesn't exist (for PNL sharing in chat)
158
+ try {
159
+ await this.pool.query(`
160
+ DO $$
161
+ BEGIN
162
+ IF NOT EXISTS (
163
+ SELECT 1 FROM information_schema.columns
164
+ WHERE table_name = 'chat_messages' AND column_name = 'pnl_share_metadata'
165
+ ) THEN
166
+ ALTER TABLE chat_messages ADD COLUMN pnl_share_metadata JSONB;
167
+ END IF;
168
+ END $$;
169
+ `);
170
+ console.log('✅ PNL share metadata column ready');
171
+ } catch (migrateError) {
172
+ console.log('⚠️ PNL share migration error:', migrateError.message);
173
+ }
174
+
175
+ // Add notification_data column to chat_notifications if it doesn't exist
176
+ try {
177
+ await this.pool.query(`
178
+ DO $$
179
+ BEGIN
180
+ IF NOT EXISTS (
181
+ SELECT 1 FROM information_schema.columns
182
+ WHERE table_name = 'chat_notifications' AND column_name = 'notification_data'
183
+ ) THEN
184
+ ALTER TABLE chat_notifications ADD COLUMN notification_data JSONB;
185
+ END IF;
186
+ END $$;
187
+ `);
188
+ console.log('✅ Notification data column ready');
189
+ } catch (notifDataError) {
190
+ console.log('⚠️ Notification data migration error:', notifDataError.message);
191
+ }
192
+
193
+
194
+ console.log('✅ Chat tables initialized (v2 - production ready)');
195
+ } catch (error) {
196
+ console.error('❌ Failed to initialize chat tables:', error.message);
197
+ console.error(' Full error:', error);
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Get recent chat messages (with replies)
203
+ * @param {number} limit - Max messages to return
204
+ * @param {number|null} userId - Current user ID (for filtering blocked users)
205
+ * @param {number|null} before - Message ID cursor (fetch messages older than this)
206
+ */
207
+ async getRecentMessages(limit = 50, userId = null, before = null) {
208
+ try {
209
+ // Get blocked users if userId provided
210
+ let blockedUsers = [];
211
+ if (userId) {
212
+ const blockResult = await this.pool.query(
213
+ `SELECT target_user_id FROM user_relationships
214
+ WHERE user_id = $1 AND relationship_type = 'block'`,
215
+ [userId]
216
+ );
217
+ blockedUsers = blockResult.rows.map(r => r.target_user_id);
218
+ }
219
+
220
+ // Build query with optional cursor
221
+ const params = [limit + 1]; // Fetch one extra to check if there are more
222
+ let paramIndex = 2;
223
+ let beforeClause = '';
224
+
225
+ if (before) {
226
+ beforeClause = `AND m.id < $${paramIndex}`;
227
+ params.push(before);
228
+ paramIndex++;
229
+ }
230
+
231
+ const result = await this.pool.query(
232
+ `SELECT
233
+ m.*,
234
+ m.game_invite_metadata,
235
+ m.pnl_share_metadata,
236
+ reply.message as reply_message,
237
+ reply.username as reply_username,
238
+ reply.wallet_address as reply_wallet_address,
239
+ g.matchup_image_url as game_matchup_image_url
240
+ FROM chat_messages m
241
+ LEFT JOIN chat_messages reply ON m.reply_to_id = reply.id
242
+ LEFT JOIN games g ON g.game_id = m.game_invite_metadata->>'gameId'
243
+ WHERE m.deleted = false
244
+ ${blockedUsers.length > 0 ? 'AND m.user_id NOT IN (' + blockedUsers.join(',') + ')' : ''}
245
+ ${beforeClause}
246
+ ORDER BY m.timestamp DESC
247
+ LIMIT $1`,
248
+ params
249
+ );
250
+
251
+ // Check if there are more messages
252
+ const hasMore = result.rows.length > limit;
253
+ const rows = hasMore ? result.rows.slice(0, limit) : result.rows;
254
+
255
+ const messages = rows.map(row => ({
256
+ id: row.id,
257
+ userId: row.user_id,
258
+ walletAddress: row.wallet_address,
259
+ username: row.username,
260
+ avatar: row.avatar,
261
+ message: row.message,
262
+ replyTo: row.reply_to_id ? {
263
+ id: row.reply_to_id,
264
+ message: row.reply_message,
265
+ username: row.reply_username,
266
+ walletAddress: row.reply_wallet_address,
267
+ } : null,
268
+ timestamp: row.timestamp,
269
+ edited: row.edited || false,
270
+ editedAt: row.edited_at,
271
+ isWinnerAnnouncement: row.is_winner_announcement || false,
272
+ winAmount: row.win_amount ? parseFloat(row.win_amount) : undefined,
273
+ roundId: row.round_id || undefined,
274
+ gameInvite: row.game_invite_metadata
275
+ ? {
276
+ ...row.game_invite_metadata,
277
+ // Use fresh matchupImageUrl from games table if available
278
+ matchupImageUrl: row.game_matchup_image_url || row.game_invite_metadata.matchupImageUrl,
279
+ }
280
+ : undefined,
281
+ pnlShare: row.pnl_share_metadata || undefined,
282
+ gifUrl: row.gif_url || undefined,
283
+ reactions: {}, // Will be populated below
284
+ mentions: [], // Will be populated below
285
+ })).reverse(); // Reverse so oldest is first (chat order)
286
+
287
+ // Get reactions for all messages
288
+ const messageIds = messages.map(m => m.id);
289
+ console.log('[ChatService] Getting reactions for', messageIds.length, 'messages');
290
+ const reactionsMap = await this.getReactionsForMessages(messageIds);
291
+ console.log('[ChatService] Reactions map:', Object.keys(reactionsMap).length, 'messages have reactions');
292
+
293
+ // Get mentions for all messages
294
+ const mentionsMap = await this.getMentionsForMessages(messageIds);
295
+ console.log('[ChatService] Mentions map:', Object.keys(mentionsMap).length, 'messages have mentions');
296
+
297
+ // Get payments for all messages
298
+ const paymentsMap = await this.getPaymentsForMessages(messageIds);
299
+ console.log('[ChatService] Payments map:', Object.keys(paymentsMap).length, 'messages have payments');
300
+
301
+ // Attach reactions, mentions, and payments to messages
302
+ messages.forEach(msg => {
303
+ msg.reactions = reactionsMap[msg.id] || {};
304
+ msg.mentions = mentionsMap[msg.id] || [];
305
+ msg.payment = paymentsMap[msg.id] || null;
306
+ });
307
+
308
+ return { messages, hasMore };
309
+ } catch (error) {
310
+ console.error('Error getting messages:', error);
311
+ return { messages: [], hasMore: false };
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Parse @mentions from message text
317
+ */
318
+ parseMentions(messageText) {
319
+ const mentionRegex = /@(\w+)/g;
320
+ const matches = [...messageText.matchAll(mentionRegex)];
321
+ const usernames = [...new Set(matches.map(m => m[1]))]; // Remove duplicates
322
+ return usernames;
323
+ }
324
+
325
+ /**
326
+ * Validate and get user IDs for mentioned usernames (case-insensitive)
327
+ */
328
+ async validateMentions(usernames) {
329
+ if (!usernames || usernames.length === 0) {
330
+ return [];
331
+ }
332
+
333
+ try {
334
+ // Use LOWER() for case-insensitive matching
335
+ const result = await this.pool.query(
336
+ 'SELECT id as user_id, username, wallet_address FROM users WHERE LOWER(username) = ANY($1)',
337
+ [usernames.map(u => u.toLowerCase())]
338
+ );
339
+
340
+ return result.rows.map(row => ({
341
+ userId: row.user_id,
342
+ username: row.username,
343
+ walletAddress: row.wallet_address
344
+ }));
345
+ } catch (error) {
346
+ console.error('[ChatService] Error validating mentions:', error);
347
+ return [];
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Save mentions to database
353
+ */
354
+ async saveMentions(messageId, mentionedUserIds) {
355
+ if (!mentionedUserIds || mentionedUserIds.length === 0) {
356
+ return;
357
+ }
358
+
359
+ try {
360
+ // Insert all mentions (ignore duplicates)
361
+ const values = mentionedUserIds.map((userId, index) =>
362
+ `($1, $${index + 2})`
363
+ ).join(',');
364
+
365
+ const query = `
366
+ INSERT INTO message_mentions (message_id, mentioned_user_id)
367
+ VALUES ${values}
368
+ ON CONFLICT (message_id, mentioned_user_id) DO NOTHING
369
+ `;
370
+
371
+ await this.pool.query(query, [messageId, ...mentionedUserIds]);
372
+ console.log(`[ChatService] Saved ${mentionedUserIds.length} mention(s) for message ${messageId}`);
373
+ } catch (error) {
374
+ console.error('[ChatService] Error saving mentions:', error);
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Create mention notifications for mentioned users
380
+ */
381
+ async createMentionNotifications(messageId, mentionedUsers, senderId, senderUsername, messageText) {
382
+ if (!mentionedUsers || mentionedUsers.length === 0) {
383
+ return [];
384
+ }
385
+
386
+ try {
387
+ const notificationIds = [];
388
+
389
+ for (const mentionedUser of mentionedUsers) {
390
+ // Don't notify if user mentioned themselves
391
+ if (mentionedUser.userId === senderId) {
392
+ continue;
393
+ }
394
+
395
+ // Check if mentioned user has blocked the sender
396
+ const blockCheck = await this.pool.query(
397
+ `SELECT 1 FROM user_relationships
398
+ WHERE user_id = $1 AND target_user_id = $2 AND relationship_type = 'block'`,
399
+ [mentionedUser.userId, senderId]
400
+ );
401
+
402
+ if (blockCheck.rows.length > 0) {
403
+ console.log(`[ChatService] User ${mentionedUser.userId} has blocked ${senderId}, skipping mention notification`);
404
+ continue;
405
+ }
406
+
407
+ // Create notification
408
+ const result = await this.pool.query(
409
+ `INSERT INTO chat_notifications (user_id, message_id, sender_user_id, notification_type, created_at)
410
+ VALUES ($1, $2, $3, 'mention', NOW())
411
+ RETURNING id, created_at`,
412
+ [mentionedUser.userId, messageId, senderId]
413
+ );
414
+
415
+ const notificationId = result.rows[0].id;
416
+ const createdAt = result.rows[0].created_at;
417
+ notificationIds.push(notificationId);
418
+
419
+ // Cache notification to Redis (non-blocking)
420
+ notificationCacheService.cacheNotification(mentionedUser.userId, {
421
+ id: notificationId,
422
+ type: 'mention',
423
+ read: false,
424
+ messageId: messageId,
425
+ message: messageText,
426
+ senderUsername: senderUsername,
427
+ senderWallet: '',
428
+ senderAvatar: null,
429
+ createdAt: createdAt,
430
+ }).catch(err => console.error('[ChatService] Failed to cache mention notification:', err.message));
431
+
432
+ // Forward to Telegram if connected
433
+ forwardChatNotification(
434
+ this.pool,
435
+ mentionedUser.userId,
436
+ 'mention',
437
+ senderUsername,
438
+ messageText
439
+ ).catch(err =>
440
+ console.error('[ChatService] Error forwarding mention notification to Telegram:', err.message)
441
+ );
442
+ }
443
+
444
+ console.log(`[ChatService] Created ${notificationIds.length} mention notification(s)`);
445
+ return notificationIds;
446
+ } catch (error) {
447
+ console.error('[ChatService] Error creating mention notifications:', error);
448
+ return [];
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Get users by their IDs (for mention lookup from client-provided IDs)
454
+ */
455
+ async getUsersByIds(userIds) {
456
+ if (!userIds || userIds.length === 0) {
457
+ return [];
458
+ }
459
+
460
+ try {
461
+ const result = await this.pool.query(
462
+ 'SELECT id as user_id, username, wallet_address FROM users WHERE id = ANY($1)',
463
+ [userIds]
464
+ );
465
+
466
+ return result.rows.map(row => ({
467
+ userId: row.user_id,
468
+ username: row.username,
469
+ walletAddress: row.wallet_address
470
+ }));
471
+ } catch (error) {
472
+ console.error('[ChatService] Error getting users by IDs:', error);
473
+ return [];
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Add a new message (v2 - with user info, replies, and mentions)
479
+ */
480
+ async addMessage(userId, walletAddress, username, avatar, message, options = {}) {
481
+ try {
482
+ // Sanitize message (max 500 chars, no XSS) - allow empty for GIF-only messages
483
+ const sanitized = (message || '').trim().substring(0, 500);
484
+
485
+ // Validate gifUrl if provided
486
+ const gifUrl = options.gifUrl || null;
487
+
488
+ if ((!sanitized || sanitized.length === 0) && !gifUrl) {
489
+ throw new Error('Message cannot be empty');
490
+ }
491
+
492
+ // Get mentioned users - prefer client-provided IDs (from autocomplete) over text parsing
493
+ let mentionedUsers = [];
494
+ if (options.mentionUserIds && options.mentionUserIds.length > 0) {
495
+ // Client provided mention user IDs (more reliable - from autocomplete)
496
+ mentionedUsers = await this.getUsersByIds(options.mentionUserIds);
497
+ console.log(`[ChatService] Found ${mentionedUsers.length} mention(s) from client-provided IDs`);
498
+ } else {
499
+ // Fallback: parse @mentions from message text (case-insensitive)
500
+ const mentionedUsernames = this.parseMentions(sanitized);
501
+ mentionedUsers = await this.validateMentions(mentionedUsernames);
502
+ console.log(`[ChatService] Found ${mentionedUsers.length} valid mention(s) from text parsing`);
503
+ }
504
+
505
+
506
+ // If replying to a message, verify it exists and create notification
507
+ let replyToId = options.replyToId || null;
508
+ let notificationId = null;
509
+ if (replyToId) {
510
+ // Get original message author - check if message exists and not deleted
511
+ const originalMsg = await this.pool.query(
512
+ 'SELECT user_id FROM chat_messages WHERE id = $1 AND deleted = false',
513
+ [replyToId]
514
+ );
515
+
516
+ if (originalMsg.rows.length > 0) {
517
+ // Message exists, we can safely reference it
518
+ if (originalMsg.rows[0].user_id !== userId) {
519
+ // Create notification for reply with sender info
520
+ const notifResult = await this.pool.query(
521
+ `INSERT INTO chat_notifications (user_id, message_id, sender_user_id, notification_type, created_at)
522
+ VALUES ($1, $2, $3, 'reply', NOW())
523
+ RETURNING id, created_at`,
524
+ [originalMsg.rows[0].user_id, replyToId, userId]
525
+ );
526
+ notificationId = notifResult.rows[0].id;
527
+ const notifCreatedAt = notifResult.rows[0].created_at;
528
+
529
+ // Cache notification to Redis (non-blocking)
530
+ notificationCacheService.cacheNotification(originalMsg.rows[0].user_id, {
531
+ id: notificationId,
532
+ type: 'reply',
533
+ read: false,
534
+ messageId: replyToId,
535
+ message: message,
536
+ senderUsername: username,
537
+ senderWallet: walletAddress,
538
+ senderAvatar: avatar,
539
+ createdAt: notifCreatedAt,
540
+ }).catch(err => console.error('[ChatService] Failed to cache reply notification:', err.message));
541
+
542
+ // Forward to Telegram if connected
543
+ forwardChatNotification(this.pool, originalMsg.rows[0].user_id, 'reply', username, message).catch(err =>
544
+ console.error('[ChatService] Error forwarding reply notification to Telegram:', err.message)
545
+ );
546
+ }
547
+ } else {
548
+ // Message doesn't exist or was deleted - clear the replyToId to prevent FK violation
549
+ console.log(`[ChatService] Warning: Cannot reply to message ${replyToId} - message not found or deleted`);
550
+ replyToId = null;
551
+ }
552
+ }
553
+
554
+ const result = await this.pool.query(
555
+ `INSERT INTO chat_messages (
556
+ user_id,
557
+ wallet_address,
558
+ username,
559
+ avatar,
560
+ message,
561
+ reply_to_id,
562
+ is_winner_announcement,
563
+ win_amount,
564
+ round_id,
565
+ game_invite_metadata,
566
+ pnl_share_metadata,
567
+ gif_url,
568
+ timestamp
569
+ )
570
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW())
571
+ RETURNING *`,
572
+ [
573
+ userId,
574
+ walletAddress,
575
+ username,
576
+ avatar,
577
+ sanitized,
578
+ replyToId,
579
+ options.isWinnerAnnouncement || false,
580
+ options.winAmount || null,
581
+ options.roundId || null,
582
+ options.gameInvite ? JSON.stringify(options.gameInvite) : null,
583
+ options.pnlShare ? JSON.stringify(options.pnlShare) : null,
584
+ gifUrl,
585
+ ]
586
+ );
587
+
588
+ const row = result.rows[0];
589
+ console.log(`💬 Message from ${username} (${walletAddress.slice(0, 8)}...)`);
590
+
591
+ // Save mentions to database
592
+ if (mentionedUsers.length > 0) {
593
+ const mentionedUserIds = mentionedUsers.map(u => u.userId);
594
+ await this.saveMentions(row.id, mentionedUserIds);
595
+
596
+ // Create mention notifications ONLY if this is not a payment message
597
+ // Payment messages will create payment_received notifications instead
598
+ if (!options.paymentSignature) {
599
+ await this.createMentionNotifications(
600
+ row.id,
601
+ mentionedUsers,
602
+ userId,
603
+ username,
604
+ sanitized
605
+ );
606
+ } else {
607
+ console.log(`[ChatService] Skipping mention notification - this is a payment message`);
608
+ }
609
+ }
610
+
611
+ return {
612
+ id: row.id,
613
+ userId: row.user_id,
614
+ walletAddress: row.wallet_address,
615
+ username: row.username,
616
+ avatar: row.avatar,
617
+ message: row.message,
618
+ replyToId: row.reply_to_id,
619
+ notificationId: notificationId, // Include notification ID if this was a reply
620
+ timestamp: row.timestamp,
621
+ edited: false,
622
+ isWinnerAnnouncement: row.is_winner_announcement || false,
623
+ winAmount: row.win_amount ? parseFloat(row.win_amount) : undefined,
624
+ mentions: mentionedUsers, // Include mentions in response
625
+ roundId: row.round_id || undefined,
626
+ gameInvite: row.game_invite_metadata || undefined,
627
+ pnlShare: row.pnl_share_metadata || undefined,
628
+ gifUrl: row.gif_url || undefined,
629
+ };
630
+ } catch (error) {
631
+ console.error('Error adding message:', error);
632
+ throw error;
633
+ }
634
+ }
635
+
636
+ /**
637
+ * 🏆 Post EPIC winner announcement to chat!
638
+ * Posts as "Jackpot" system identity and @mentions the winner.
639
+ */
640
+ async postWinnerAnnouncement({ winner, winAmount, roundId }) {
641
+ try {
642
+ // Look up winner's username for the @mention
643
+ let winnerUsername = winner.slice(0, 8) + '...';
644
+ let winnerUserId = null;
645
+ try {
646
+ const userResult = await this.pool.query(
647
+ 'SELECT id, username FROM users WHERE wallet_address = $1',
648
+ [winner]
649
+ );
650
+ if (userResult.rows.length > 0) {
651
+ winnerUserId = userResult.rows[0].id;
652
+ winnerUsername = userResult.rows[0].username || winnerUsername;
653
+ }
654
+ } catch (lookupErr) {
655
+ console.warn('⚠️ Could not look up winner profile:', lookupErr.message);
656
+ }
657
+
658
+ const message = `🎰 @${winnerUsername} just won the Jackpot! 🎉`;
659
+
660
+ // Post as system "Jackpot" identity (null userId = system message)
661
+ const result = await this.pool.query(
662
+ `INSERT INTO chat_messages (
663
+ user_id,
664
+ wallet_address,
665
+ username,
666
+ avatar,
667
+ message,
668
+ is_winner_announcement,
669
+ win_amount,
670
+ round_id,
671
+ timestamp
672
+ )
673
+ VALUES ($1, $2, $3, $4, $5, TRUE, $6, $7, NOW())
674
+ RETURNING *`,
675
+ [null, 'system', 'Jackpot', '/dubs-jackpot.png', message, winAmount, roundId]
676
+ );
677
+
678
+ const row = result.rows[0];
679
+ console.log(`🏆 WINNER ANNOUNCEMENT posted for ${winnerUsername} (${winner.slice(0, 8)}...) - ◎${winAmount}`);
680
+
681
+ return {
682
+ id: row.id,
683
+ userId: row.user_id,
684
+ walletAddress: 'system',
685
+ username: 'Jackpot',
686
+ avatar: '/dubs-jackpot.png',
687
+ message: row.message,
688
+ timestamp: row.timestamp,
689
+ isWinnerAnnouncement: true,
690
+ winAmount: parseFloat(row.win_amount),
691
+ roundId: row.round_id,
692
+ };
693
+ } catch (error) {
694
+ console.error('Error posting winner announcement:', error);
695
+ throw error;
696
+ }
697
+ }
698
+
699
+ /**
700
+ * Delete old messages (keep last 1000)
701
+ */
702
+ async cleanup() {
703
+ try {
704
+ await this.pool.query(`
705
+ DELETE FROM chat_messages
706
+ WHERE id NOT IN (
707
+ SELECT id FROM chat_messages
708
+ ORDER BY timestamp DESC
709
+ LIMIT 1000
710
+ )
711
+ `);
712
+ console.log('🧹 Cleaned up old chat messages');
713
+ } catch (error) {
714
+ console.error('Error cleaning up messages:', error);
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Get message count for a user (rate limiting)
720
+ */
721
+ async getRecentMessageCount(userId, minutes = 1) {
722
+ try {
723
+ const result = await this.pool.query(
724
+ `SELECT COUNT(*) as count
725
+ FROM chat_messages
726
+ WHERE user_id = $1
727
+ AND timestamp > NOW() - INTERVAL '${minutes} minutes'`,
728
+ [userId]
729
+ );
730
+
731
+ return parseInt(result.rows[0].count);
732
+ } catch (error) {
733
+ console.error('Error counting messages:', error);
734
+ return 0;
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Delete a message (soft delete)
740
+ */
741
+ async deleteMessage(messageId, userId) {
742
+ try {
743
+ const result = await this.pool.query(
744
+ `UPDATE chat_messages
745
+ SET deleted = true, deleted_at = NOW()
746
+ WHERE id = $1 AND user_id = $2
747
+ RETURNING id`,
748
+ [messageId, userId]
749
+ );
750
+
751
+ return result.rows.length > 0;
752
+ } catch (error) {
753
+ console.error('Error deleting message:', error);
754
+ return false;
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Edit a message
760
+ */
761
+ async editMessage(messageId, userId, newMessage) {
762
+ try {
763
+ const sanitized = newMessage.trim().substring(0, 500);
764
+
765
+ const result = await this.pool.query(
766
+ `UPDATE chat_messages
767
+ SET message = $1, edited = true, edited_at = NOW()
768
+ WHERE id = $2 AND user_id = $3
769
+ RETURNING *`,
770
+ [sanitized, messageId, userId]
771
+ );
772
+
773
+ if (result.rows.length === 0) return null;
774
+
775
+ const row = result.rows[0];
776
+ return {
777
+ id: row.id,
778
+ message: row.message,
779
+ edited: true,
780
+ editedAt: row.edited_at,
781
+ };
782
+ } catch (error) {
783
+ console.error('Error editing message:', error);
784
+ return null;
785
+ }
786
+ }
787
+
788
+ /**
789
+ * Block a user
790
+ */
791
+ async blockUser(userId, targetUserId) {
792
+ try {
793
+ await this.pool.query(
794
+ `INSERT INTO user_relationships (user_id, target_user_id, relationship_type)
795
+ VALUES ($1, $2, 'block')
796
+ ON CONFLICT (user_id, target_user_id)
797
+ DO UPDATE SET relationship_type = 'block'`,
798
+ [userId, targetUserId]
799
+ );
800
+ return true;
801
+ } catch (error) {
802
+ console.error('Error blocking user:', error);
803
+ return false;
804
+ }
805
+ }
806
+
807
+ /**
808
+ * Unblock a user
809
+ */
810
+ async unblockUser(userId, targetUserId) {
811
+ try {
812
+ await this.pool.query(
813
+ `DELETE FROM user_relationships
814
+ WHERE user_id = $1 AND target_user_id = $2 AND relationship_type = 'block'`,
815
+ [userId, targetUserId]
816
+ );
817
+ return true;
818
+ } catch (error) {
819
+ console.error('Error unblocking user:', error);
820
+ return false;
821
+ }
822
+ }
823
+
824
+ /**
825
+ * Get user's notifications with cursor-based pagination
826
+ *
827
+ * @param {number} userId - User ID
828
+ * @param {Object} options - Pagination options
829
+ * @param {number} options.limit - Number of notifications (default: 10)
830
+ * @param {number|null} options.cursor - Timestamp cursor for pagination
831
+ * @returns {Object} - { notifications, nextCursor, hasMore, total }
832
+ */
833
+ async getNotifications(userId, { limit = 10, cursor = null } = {}) {
834
+ try {
835
+ // Try Redis cache first
836
+ if (notificationCacheService.isEnabled()) {
837
+ const cached = await notificationCacheService.getNotifications(userId, { limit, cursor });
838
+ if (cached) {
839
+ console.log(`[ChatService] ⚡ Cache hit: ${cached.notifications.length} notifications for user ${userId}`);
840
+ return cached;
841
+ }
842
+ console.log(`[ChatService] Cache miss for user ${userId} - fetching from PostgreSQL`);
843
+ }
844
+
845
+ // PostgreSQL fallback with cursor-based pagination
846
+ const notifications = await this._getNotificationsFromDB(userId, { limit, cursor });
847
+
848
+ // Warm the cache if Redis is available and this is the first page
849
+ if (notificationCacheService.isEnabled() && !cursor) {
850
+ // Fetch more for cache warming (up to 100)
851
+ const cacheNotifications = await this._getNotificationsFromDB(userId, { limit: 100, cursor: null });
852
+ notificationCacheService.bulkCacheNotifications(userId, cacheNotifications.notifications)
853
+ .catch(err => console.error('[ChatService] Cache warming failed:', err.message));
854
+ }
855
+
856
+ return notifications;
857
+ } catch (error) {
858
+ console.error('Error getting notifications:', error);
859
+ return { notifications: [], nextCursor: null, hasMore: false, total: 0 };
860
+ }
861
+ }
862
+
863
+ /**
864
+ * Fetch notifications from PostgreSQL with cursor-based pagination
865
+ * @private
866
+ */
867
+ async _getNotificationsFromDB(userId, { limit = 10, cursor = null } = {}) {
868
+ try {
869
+ // Build query with optional cursor
870
+ // JOIN with games table to get current matchup_image_url (fixes old notifications)
871
+ let query = `
872
+ SELECT
873
+ n.id,
874
+ n.user_id,
875
+ n.message_id,
876
+ n.sender_user_id,
877
+ n.notification_type,
878
+ n.read,
879
+ n.created_at,
880
+ n.notification_data,
881
+ m.message,
882
+ -- Get the most recent reaction emoji for reaction notifications
883
+ (SELECT reaction FROM chat_reactions
884
+ WHERE message_id = n.message_id
885
+ AND user_id = n.sender_user_id
886
+ ORDER BY created_at DESC
887
+ LIMIT 1) as reaction,
888
+ -- Get sender info from sender_user_id
889
+ sender.username as sender_username,
890
+ sender.wallet_address as sender_wallet,
891
+ sender.avatar as sender_avatar,
892
+ -- Get current matchup image URL from games table (enriches old notifications)
893
+ g.matchup_image_url as game_matchup_image_url
894
+ FROM chat_notifications n
895
+ LEFT JOIN chat_messages m ON n.message_id = m.id
896
+ LEFT JOIN users sender ON n.sender_user_id = sender.id
897
+ LEFT JOIN games g ON g.game_id = n.notification_data->'gameInvite'->>'gameId'
898
+ WHERE n.user_id = $1`;
899
+
900
+ const params = [userId];
901
+
902
+ if (cursor) {
903
+ // Cursor is a timestamp - fetch notifications before this time
904
+ query += ` AND n.created_at < $2`;
905
+ params.push(new Date(parseInt(cursor)));
906
+ }
907
+
908
+ query += ` ORDER BY n.created_at DESC LIMIT $${params.length + 1}`;
909
+ params.push(limit + 1); // Fetch one extra to detect hasMore
910
+
911
+ const result = await this.pool.query(query, params);
912
+
913
+ // Deduplicate reactions (keep most recent per message+sender)
914
+ const seenReactions = new Map();
915
+ const deduplicatedRows = [];
916
+
917
+ for (const row of result.rows) {
918
+ if (row.notification_type === 'reaction') {
919
+ const key = `${row.message_id}-${row.sender_user_id}`;
920
+ if (!seenReactions.has(key)) {
921
+ seenReactions.set(key, row);
922
+ deduplicatedRows.push(row);
923
+ }
924
+ } else {
925
+ deduplicatedRows.push(row);
926
+ }
927
+ }
928
+
929
+ // Check if there are more results
930
+ const hasMore = deduplicatedRows.length > limit;
931
+ const limitedRows = hasMore ? deduplicatedRows.slice(0, limit) : deduplicatedRows;
932
+
933
+ // Calculate next cursor
934
+ let nextCursor = null;
935
+ if (hasMore && limitedRows.length > 0) {
936
+ const lastRow = limitedRows[limitedRows.length - 1];
937
+ nextCursor = new Date(lastRow.created_at).getTime();
938
+ }
939
+
940
+ // Transform rows to notification objects
941
+ const notifications = limitedRows.map(row => this._transformNotificationRow(row));
942
+
943
+ console.log(`[ChatService] 📊 Fetched ${notifications.length} notifications from DB (hasMore: ${hasMore})`);
944
+
945
+ return {
946
+ notifications,
947
+ nextCursor,
948
+ hasMore,
949
+ };
950
+ } catch (error) {
951
+ console.error('Error fetching notifications from DB:', error);
952
+ return { notifications: [], nextCursor: null, hasMore: false };
953
+ }
954
+ }
955
+
956
+ /**
957
+ * Transform a database row to a notification object
958
+ * @private
959
+ */
960
+ _transformNotificationRow(row) {
961
+ const notification = {
962
+ id: row.id,
963
+ messageId: row.message_id,
964
+ type: row.notification_type,
965
+ read: row.read,
966
+ message: row.notification_type === 'reaction'
967
+ ? (row.reaction || '')
968
+ : row.notification_type === 'game_joined' && row.notification_data?.teamChoice
969
+ ? row.notification_data.teamChoice
970
+ : (row.message || ''),
971
+ senderUsername: row.sender_username || 'Unknown',
972
+ senderWallet: row.sender_wallet || '',
973
+ senderAvatar: row.sender_avatar || null,
974
+ createdAt: row.created_at,
975
+ };
976
+
977
+ // Add game invite for game-related notifications
978
+ if (['game_joined', 'game_invite', 'game_starting_soon', 'game_starting_now', 'game_won', 'game_lost', 'connect4_your_turn']
979
+ .includes(row.notification_type) && row.notification_data?.gameInvite) {
980
+ notification.gameInvite = { ...row.notification_data.gameInvite };
981
+
982
+ // Use S3 matchupImageUrl from games table (JOIN) if available
983
+ if (row.game_matchup_image_url) {
984
+ notification.gameInvite.matchupImageUrl = row.game_matchup_image_url;
985
+ }
986
+ }
987
+
988
+ // Add message from notification_data for game notifications
989
+ if (['game_starting_soon', 'game_starting_now', 'game_won', 'game_lost']
990
+ .includes(row.notification_type) && row.notification_data?.message) {
991
+ notification.message = row.notification_data.message;
992
+ }
993
+
994
+ // Add final score for game result notifications
995
+ if (['game_won', 'game_lost'].includes(row.notification_type) && row.notification_data?.finalScore) {
996
+ notification.finalScore = row.notification_data.finalScore;
997
+ }
998
+
999
+ // Add jackpot data (win or loss)
1000
+ if ((row.notification_type === 'game_won' || row.notification_type === 'game_lost') && row.notification_data?.jackpotWin) {
1001
+ notification.jackpotWin = row.notification_data.jackpotWin;
1002
+ }
1003
+
1004
+ // Add amount for game_joined notifications (pari-mutuel: joiner's actual bet amount)
1005
+ if (row.notification_type === 'game_joined' && row.notification_data?.amount !== undefined) {
1006
+ notification.amount = row.notification_data.amount;
1007
+ }
1008
+
1009
+ // Add message preview for DM notifications
1010
+ if (row.notification_type === 'dm_message' && row.notification_data?.preview) {
1011
+ notification.message = row.notification_data.preview;
1012
+ }
1013
+
1014
+ return notification;
1015
+ }
1016
+
1017
+
1018
+ /**
1019
+ * Mark notifications as read
1020
+ * Updates PostgreSQL and syncs with Redis cache
1021
+ */
1022
+ async markNotificationsRead(userId, notificationIds) {
1023
+ try {
1024
+ // Update PostgreSQL (source of truth)
1025
+ await this.pool.query(
1026
+ `UPDATE chat_notifications
1027
+ SET read = true
1028
+ WHERE user_id = $1 AND id = ANY($2)`,
1029
+ [userId, notificationIds]
1030
+ );
1031
+
1032
+ // Sync with Redis cache (non-blocking)
1033
+ notificationCacheService.markAsRead(userId, notificationIds)
1034
+ .catch(err => console.error('[ChatService] Failed to sync read status to cache:', err.message));
1035
+
1036
+ return true;
1037
+ } catch (error) {
1038
+ console.error('Error marking notifications read:', error);
1039
+ return false;
1040
+ }
1041
+ }
1042
+
1043
+ /**
1044
+ * Get unread notification count
1045
+ * Uses Redis cache for high-speed access, falls back to PostgreSQL
1046
+ */
1047
+ async getUnreadCount(userId) {
1048
+ try {
1049
+ // Try Redis cache first
1050
+ if (notificationCacheService.isEnabled()) {
1051
+ const cached = await notificationCacheService.getUnreadCount(userId);
1052
+ if (cached !== null) {
1053
+ console.log(`[ChatService] ⚡ Cache hit: unread count = ${cached} for user ${userId}`);
1054
+ return cached;
1055
+ }
1056
+ }
1057
+
1058
+ // PostgreSQL fallback
1059
+ const result = await this.pool.query(
1060
+ `SELECT COUNT(*) as count
1061
+ FROM chat_notifications
1062
+ WHERE user_id = $1 AND read = false`,
1063
+ [userId]
1064
+ );
1065
+
1066
+ const count = parseInt(result.rows[0].count);
1067
+
1068
+ // Cache the count for next time
1069
+ if (notificationCacheService.isEnabled()) {
1070
+ notificationCacheService.setUnreadCount(userId, count)
1071
+ .catch(err => console.error('[ChatService] Failed to cache unread count:', err.message));
1072
+ }
1073
+
1074
+ return count;
1075
+ } catch (error) {
1076
+ console.error('Error getting unread count:', error);
1077
+ return 0;
1078
+ }
1079
+ }
1080
+
1081
+ /**
1082
+ * Add reaction to a message
1083
+ */
1084
+ async addReaction(messageId, userId, walletAddress, reaction) {
1085
+ try {
1086
+ console.log('[ChatService] Adding reaction:', { messageId, userId, walletAddress, reaction });
1087
+
1088
+ // Add reaction
1089
+ const reactionResult = await this.pool.query(
1090
+ `INSERT INTO chat_reactions (message_id, user_id, wallet_address, reaction)
1091
+ VALUES ($1, $2, $3, $4)
1092
+ ON CONFLICT (message_id, user_id, reaction) DO NOTHING
1093
+ RETURNING id`,
1094
+ [messageId, userId, walletAddress, reaction]
1095
+ );
1096
+
1097
+ console.log('[ChatService] Reaction inserted, rows returned:', reactionResult.rows.length);
1098
+
1099
+ // If new reaction was added (not duplicate), create notification for message author
1100
+ if (reactionResult.rows.length > 0) {
1101
+ // Get message author
1102
+ const msgResult = await this.pool.query(
1103
+ 'SELECT user_id FROM chat_messages WHERE id = $1',
1104
+ [messageId]
1105
+ );
1106
+
1107
+ if (msgResult.rows.length > 0) {
1108
+ const authorUserId = msgResult.rows[0].user_id;
1109
+
1110
+ // Don't notify yourself
1111
+ if (authorUserId !== userId) {
1112
+ const notifResult = await this.pool.query(
1113
+ `INSERT INTO chat_notifications (user_id, message_id, sender_user_id, notification_type, created_at)
1114
+ VALUES ($1, $2, $3, 'reaction', NOW())
1115
+ RETURNING id, created_at`,
1116
+ [authorUserId, messageId, userId]
1117
+ );
1118
+
1119
+ const notificationId = notifResult.rows[0].id;
1120
+ const notifCreatedAt = notifResult.rows[0].created_at;
1121
+
1122
+ // Get username for Telegram notification
1123
+ const userResult = await this.pool.query('SELECT username, wallet_address, avatar FROM users WHERE id = $1', [userId]);
1124
+ const senderUsername = userResult.rows[0]?.username || 'Someone';
1125
+ const senderWallet = userResult.rows[0]?.wallet_address || '';
1126
+ const senderAvatar = userResult.rows[0]?.avatar || null;
1127
+
1128
+ // Cache notification to Redis (non-blocking)
1129
+ notificationCacheService.cacheNotification(authorUserId, {
1130
+ id: notificationId,
1131
+ type: 'reaction',
1132
+ read: false,
1133
+ messageId: messageId,
1134
+ message: reaction,
1135
+ senderUsername: senderUsername,
1136
+ senderWallet: senderWallet,
1137
+ senderAvatar: senderAvatar,
1138
+ createdAt: notifCreatedAt,
1139
+ }).catch(err => console.error('[ChatService] Failed to cache reaction notification:', err.message));
1140
+
1141
+ // Forward to Telegram if connected
1142
+ forwardChatNotification(this.pool, authorUserId, 'reaction', senderUsername, reaction).catch(err =>
1143
+ console.error('[ChatService] Error forwarding reaction notification to Telegram:', err.message)
1144
+ );
1145
+
1146
+ return {
1147
+ success: true,
1148
+ notificationId: notifResult.rows[0]?.id,
1149
+ authorUserId,
1150
+ };
1151
+ }
1152
+ }
1153
+ }
1154
+
1155
+ return { success: true };
1156
+ } catch (error) {
1157
+ console.error('Error adding reaction:', error);
1158
+ return { success: false };
1159
+ }
1160
+ }
1161
+
1162
+ /**
1163
+ * Remove reaction from a message
1164
+ */
1165
+ async removeReaction(messageId, userId, reaction) {
1166
+ try {
1167
+ await this.pool.query(
1168
+ `DELETE FROM chat_reactions
1169
+ WHERE message_id = $1 AND user_id = $2 AND reaction = $3`,
1170
+ [messageId, userId, reaction]
1171
+ );
1172
+ return true;
1173
+ } catch (error) {
1174
+ console.error('Error removing reaction:', error);
1175
+ return false;
1176
+ }
1177
+ }
1178
+
1179
+ /**
1180
+ * Get reactions for messages
1181
+ */
1182
+ async getReactionsForMessages(messageIds) {
1183
+ try {
1184
+ if (messageIds.length === 0) return {};
1185
+
1186
+ const result = await this.pool.query(
1187
+ `SELECT
1188
+ message_id,
1189
+ reaction,
1190
+ COUNT(*) as count,
1191
+ array_agg(wallet_address) as reactors
1192
+ FROM chat_reactions
1193
+ WHERE message_id = ANY($1)
1194
+ GROUP BY message_id, reaction`,
1195
+ [messageIds]
1196
+ );
1197
+
1198
+ // Transform into map: messageId -> { emoji: { count, reactors } }
1199
+ const reactionsMap = {};
1200
+ result.rows.forEach(row => {
1201
+ if (!reactionsMap[row.message_id]) {
1202
+ reactionsMap[row.message_id] = {};
1203
+ }
1204
+ reactionsMap[row.message_id][row.reaction] = {
1205
+ count: parseInt(row.count),
1206
+ reactors: row.reactors,
1207
+ };
1208
+ });
1209
+
1210
+ console.log('[ChatService] Built reactions map:', reactionsMap);
1211
+ return reactionsMap;
1212
+ } catch (error) {
1213
+ console.error('Error getting reactions:', error);
1214
+ return {};
1215
+ }
1216
+ }
1217
+
1218
+ /**
1219
+ * Get mentions for messages
1220
+ */
1221
+ async getMentionsForMessages(messageIds) {
1222
+ try {
1223
+ if (messageIds.length === 0) return {};
1224
+
1225
+ const result = await this.pool.query(
1226
+ `SELECT
1227
+ mm.message_id,
1228
+ u.id as user_id,
1229
+ u.username,
1230
+ u.wallet_address
1231
+ FROM message_mentions mm
1232
+ JOIN users u ON mm.mentioned_user_id = u.id
1233
+ WHERE mm.message_id = ANY($1)`,
1234
+ [messageIds]
1235
+ );
1236
+
1237
+ // Transform into map: messageId -> [{ userId, username, walletAddress }]
1238
+ const mentionsMap = {};
1239
+ result.rows.forEach(row => {
1240
+ if (!mentionsMap[row.message_id]) {
1241
+ mentionsMap[row.message_id] = [];
1242
+ }
1243
+ mentionsMap[row.message_id].push({
1244
+ userId: row.user_id,
1245
+ username: row.username,
1246
+ walletAddress: row.wallet_address,
1247
+ });
1248
+ });
1249
+
1250
+ console.log('[ChatService] Built mentions map:', Object.keys(mentionsMap).length, 'messages with mentions');
1251
+ return mentionsMap;
1252
+ } catch (error) {
1253
+ console.error('Error getting mentions:', error);
1254
+ return {};
1255
+ }
1256
+ }
1257
+
1258
+ /**
1259
+ * Save payment to database
1260
+ */
1261
+ async savePayment({
1262
+ messageId,
1263
+ senderUserId,
1264
+ senderWallet,
1265
+ recipientUserId,
1266
+ recipientWallet,
1267
+ amountSol,
1268
+ transactionSignature
1269
+ }) {
1270
+ try {
1271
+ // Validate payment before saving
1272
+ await this.validatePayment({
1273
+ senderUserId,
1274
+ recipientUserId,
1275
+ amount: amountSol,
1276
+ transactionSignature
1277
+ });
1278
+
1279
+ const result = await this.pool.query(
1280
+ `INSERT INTO chat_payments (
1281
+ message_id,
1282
+ sender_user_id,
1283
+ sender_wallet,
1284
+ recipient_user_id,
1285
+ recipient_wallet,
1286
+ amount_sol,
1287
+ transaction_signature,
1288
+ status,
1289
+ created_at
1290
+ )
1291
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'confirmed', NOW())
1292
+ RETURNING id`,
1293
+ [messageId, senderUserId, senderWallet, recipientUserId, recipientWallet, amountSol, transactionSignature]
1294
+ );
1295
+
1296
+ const paymentId = result.rows[0].id;
1297
+ console.log(`[ChatService] 💰 Payment saved as CONFIRMED: ${paymentId} (◎${amountSol} SOL)`);
1298
+
1299
+ // Create notification for recipient
1300
+ const notifResult = await this.pool.query(
1301
+ `INSERT INTO chat_notifications (
1302
+ user_id,
1303
+ message_id,
1304
+ sender_user_id,
1305
+ notification_type,
1306
+ notification_data,
1307
+ created_at
1308
+ )
1309
+ VALUES ($1, $2, $3, 'payment_received', $4, NOW())
1310
+ RETURNING id, created_at`,
1311
+ [
1312
+ recipientUserId,
1313
+ messageId,
1314
+ senderUserId,
1315
+ JSON.stringify({ amount: amountSol, signature: transactionSignature })
1316
+ ]
1317
+ );
1318
+
1319
+ const notificationId = notifResult.rows[0].id;
1320
+ const notifCreatedAt = notifResult.rows[0].created_at;
1321
+
1322
+ // Forward to Telegram if connected
1323
+ const recipientResult = await this.pool.query(
1324
+ 'SELECT username FROM users WHERE id = $1',
1325
+ [recipientUserId]
1326
+ );
1327
+ const senderResult = await this.pool.query(
1328
+ 'SELECT username, wallet_address, avatar FROM users WHERE id = $1',
1329
+ [senderUserId]
1330
+ );
1331
+
1332
+ if (recipientResult.rows.length > 0 && senderResult.rows.length > 0) {
1333
+ const recipientUsername = recipientResult.rows[0].username;
1334
+ const senderUsername = senderResult.rows[0].username;
1335
+ const senderWalletAddr = senderResult.rows[0].wallet_address;
1336
+ const senderAvatarUrl = senderResult.rows[0].avatar;
1337
+
1338
+ // Cache notification to Redis (non-blocking)
1339
+ notificationCacheService.cacheNotification(recipientUserId, {
1340
+ id: notificationId,
1341
+ type: 'payment_received',
1342
+ read: false,
1343
+ messageId: messageId,
1344
+ message: `◎${amountSol} SOL`,
1345
+ senderUsername: senderUsername,
1346
+ senderWallet: senderWalletAddr,
1347
+ senderAvatar: senderAvatarUrl,
1348
+ createdAt: notifCreatedAt,
1349
+ notificationData: { amount: amountSol, signature: transactionSignature },
1350
+ }).catch(err => console.error('[ChatService] Failed to cache payment_received notification:', err.message));
1351
+
1352
+ forwardChatNotification(
1353
+ this.pool,
1354
+ recipientUserId,
1355
+ 'payment_received',
1356
+ senderUsername,
1357
+ `Sent you ◎${amountSol} SOL`
1358
+ ).catch(err =>
1359
+ console.error('[ChatService] Error forwarding payment notification to Telegram:', err.message)
1360
+ );
1361
+ }
1362
+
1363
+ return paymentId;
1364
+ } catch (error) {
1365
+ console.error('[ChatService] Error saving payment:', error);
1366
+ throw error;
1367
+ }
1368
+ }
1369
+
1370
+ /**
1371
+ * Validate payment before saving
1372
+ */
1373
+ async validatePayment({
1374
+ senderUserId,
1375
+ recipientUserId,
1376
+ amount,
1377
+ transactionSignature
1378
+ }) {
1379
+ // 1. Prevent self-payment
1380
+ if (senderUserId === recipientUserId) {
1381
+ throw new Error('Cannot send payment to yourself');
1382
+ }
1383
+
1384
+ // 2. Validate amount
1385
+ if (amount < 0.001 || amount > 1000) {
1386
+ throw new Error('Invalid amount (must be between 0.001 and 1000 SOL)');
1387
+ }
1388
+
1389
+ // 4. Check for duplicate transaction
1390
+ const existingPayment = await this.pool.query(
1391
+ 'SELECT id FROM chat_payments WHERE transaction_signature = $1',
1392
+ [transactionSignature]
1393
+ );
1394
+
1395
+ if (existingPayment.rows.length > 0) {
1396
+ throw new Error('Transaction already recorded');
1397
+ }
1398
+
1399
+ return true;
1400
+ }
1401
+
1402
+ /**
1403
+ * Update payment status (called after Solana confirmation)
1404
+ */
1405
+ async updatePaymentStatus(transactionSignature, status, errorMessage = null) {
1406
+ try {
1407
+ const result = await this.pool.query(
1408
+ `UPDATE chat_payments
1409
+ SET status = $1, error_message = $2, confirmed_at = NOW()
1410
+ WHERE transaction_signature = $3
1411
+ RETURNING id, sender_user_id, recipient_user_id, amount_sol`,
1412
+ [status, errorMessage, transactionSignature]
1413
+ );
1414
+
1415
+ if (result.rows.length > 0) {
1416
+ const payment = result.rows[0];
1417
+ console.log(`[ChatService] Payment ${payment.id} updated to ${status}`);
1418
+
1419
+ // If confirmed, create additional notification for sender
1420
+ if (status === 'confirmed') {
1421
+ const notifResult = await this.pool.query(
1422
+ `INSERT INTO chat_notifications (
1423
+ user_id,
1424
+ sender_user_id,
1425
+ notification_type,
1426
+ notification_data,
1427
+ created_at
1428
+ )
1429
+ VALUES ($1, $2, 'payment_sent', $3, NOW())
1430
+ RETURNING id, created_at`,
1431
+ [
1432
+ payment.sender_user_id,
1433
+ payment.recipient_user_id,
1434
+ JSON.stringify({
1435
+ amount: parseFloat(payment.amount_sol),
1436
+ signature: transactionSignature
1437
+ })
1438
+ ]
1439
+ );
1440
+
1441
+ const notificationId = notifResult.rows[0].id;
1442
+ const notifCreatedAt = notifResult.rows[0].created_at;
1443
+
1444
+ // Get recipient info for the notification
1445
+ const recipientResult = await this.pool.query(
1446
+ 'SELECT username, wallet_address, avatar FROM users WHERE id = $1',
1447
+ [payment.recipient_user_id]
1448
+ );
1449
+ const recipientUsername = recipientResult.rows[0]?.username || 'Someone';
1450
+ const recipientWallet = recipientResult.rows[0]?.wallet_address || '';
1451
+ const recipientAvatar = recipientResult.rows[0]?.avatar || null;
1452
+
1453
+ // Cache notification to Redis (non-blocking)
1454
+ notificationCacheService.cacheNotification(payment.sender_user_id, {
1455
+ id: notificationId,
1456
+ type: 'payment_sent',
1457
+ read: false,
1458
+ messageId: null,
1459
+ message: `◎${parseFloat(payment.amount_sol)} SOL`,
1460
+ senderUsername: recipientUsername,
1461
+ senderWallet: recipientWallet,
1462
+ senderAvatar: recipientAvatar,
1463
+ createdAt: notifCreatedAt,
1464
+ notificationData: { amount: parseFloat(payment.amount_sol), signature: transactionSignature },
1465
+ }).catch(err => console.error('[ChatService] Failed to cache payment_sent notification:', err.message));
1466
+ }
1467
+
1468
+ return true;
1469
+ }
1470
+ return false;
1471
+ } catch (error) {
1472
+ console.error('[ChatService] Error updating payment status:', error);
1473
+ return false;
1474
+ }
1475
+ }
1476
+
1477
+ /**
1478
+ * Get payment info for a message
1479
+ */
1480
+ async getPaymentForMessage(messageId) {
1481
+ try {
1482
+ const result = await this.pool.query(
1483
+ `SELECT
1484
+ p.*,
1485
+ sender.username as sender_username,
1486
+ recipient.username as recipient_username
1487
+ FROM chat_payments p
1488
+ JOIN users sender ON p.sender_user_id = sender.id
1489
+ JOIN users recipient ON p.recipient_user_id = recipient.id
1490
+ WHERE p.message_id = $1`,
1491
+ [messageId]
1492
+ );
1493
+
1494
+ if (result.rows.length > 0) {
1495
+ return {
1496
+ id: result.rows[0].id,
1497
+ senderUsername: result.rows[0].sender_username,
1498
+ recipientUsername: result.rows[0].recipient_username,
1499
+ amount: parseFloat(result.rows[0].amount_sol),
1500
+ signature: result.rows[0].transaction_signature,
1501
+ status: result.rows[0].status,
1502
+ errorMessage: result.rows[0].error_message,
1503
+ createdAt: result.rows[0].created_at,
1504
+ confirmedAt: result.rows[0].confirmed_at,
1505
+ };
1506
+ }
1507
+ return null;
1508
+ } catch (error) {
1509
+ console.error('[ChatService] Error getting payment:', error);
1510
+ return null;
1511
+ }
1512
+ }
1513
+
1514
+ /**
1515
+ * Get payments for multiple messages
1516
+ */
1517
+ async getPaymentsForMessages(messageIds) {
1518
+ try {
1519
+ if (messageIds.length === 0) return {};
1520
+
1521
+ const result = await this.pool.query(
1522
+ `SELECT
1523
+ p.message_id,
1524
+ p.amount_sol,
1525
+ p.transaction_signature,
1526
+ p.status,
1527
+ p.error_message,
1528
+ sender.username as sender_username,
1529
+ recipient.username as recipient_username
1530
+ FROM chat_payments p
1531
+ JOIN users sender ON p.sender_user_id = sender.id
1532
+ JOIN users recipient ON p.recipient_user_id = recipient.id
1533
+ WHERE p.message_id = ANY($1)`,
1534
+ [messageIds]
1535
+ );
1536
+
1537
+ // Transform into map: messageId -> payment data
1538
+ const paymentsMap = {};
1539
+ result.rows.forEach(row => {
1540
+ paymentsMap[row.message_id] = {
1541
+ amount: parseFloat(row.amount_sol),
1542
+ signature: row.transaction_signature,
1543
+ status: row.status,
1544
+ errorMessage: row.error_message,
1545
+ senderUsername: row.sender_username,
1546
+ recipientUsername: row.recipient_username,
1547
+ };
1548
+ });
1549
+
1550
+ console.log('[ChatService] Built payments map:', Object.keys(paymentsMap).length, 'messages with payments');
1551
+ return paymentsMap;
1552
+ } catch (error) {
1553
+ console.error('[ChatService] Error getting payments:', error);
1554
+ return {};
1555
+ }
1556
+ }
1557
+
1558
+ /**
1559
+ * Get user's payment history
1560
+ */
1561
+ async getPaymentHistory(userId, limit = 50) {
1562
+ try {
1563
+ const result = await this.pool.query(
1564
+ `SELECT
1565
+ p.id,
1566
+ p.message_id,
1567
+ p.amount_sol,
1568
+ p.transaction_signature,
1569
+ p.status,
1570
+ p.created_at,
1571
+ p.confirmed_at,
1572
+ sender.username as sender_username,
1573
+ sender.wallet_address as sender_wallet,
1574
+ recipient.username as recipient_username,
1575
+ recipient.wallet_address as recipient_wallet,
1576
+ CASE
1577
+ WHEN p.sender_user_id = $1 THEN 'sent'
1578
+ WHEN p.recipient_user_id = $1 THEN 'received'
1579
+ END as direction
1580
+ FROM chat_payments p
1581
+ JOIN users sender ON p.sender_user_id = sender.id
1582
+ JOIN users recipient ON p.recipient_user_id = recipient.id
1583
+ WHERE p.sender_user_id = $1 OR p.recipient_user_id = $1
1584
+ ORDER BY p.created_at DESC
1585
+ LIMIT $2`,
1586
+ [userId, limit]
1587
+ );
1588
+
1589
+ return result.rows.map(row => ({
1590
+ id: row.id,
1591
+ messageId: row.message_id,
1592
+ amount: parseFloat(row.amount_sol),
1593
+ signature: row.transaction_signature,
1594
+ status: row.status,
1595
+ direction: row.direction,
1596
+ senderUsername: row.sender_username,
1597
+ senderWallet: row.sender_wallet,
1598
+ recipientUsername: row.recipient_username,
1599
+ recipientWallet: row.recipient_wallet,
1600
+ createdAt: row.created_at,
1601
+ confirmedAt: row.confirmed_at,
1602
+ }));
1603
+ } catch (error) {
1604
+ console.error('[ChatService] Error getting payment history:', error);
1605
+ return [];
1606
+ }
1607
+ }
1608
+ }
1609
+
1610
+ module.exports = ChatService;
1611
+
1612
+