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