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,1202 @@
1
+ /**
2
+ * 👥 Social/Friends Service
3
+ *
4
+ * Manages friend requests, friends, groups, and user search
5
+ */
6
+
7
+ const { pool } = require('./db'); // Shared database pool
8
+ const { forwardChatNotification } = require('./telegramNotifications');
9
+ const notificationCacheService = require('./notificationCacheService');
10
+
11
+ class SocialService {
12
+ constructor() {
13
+ // Use shared pool from services/db.js
14
+ this.pool = pool;
15
+
16
+ this.initializeTables();
17
+ }
18
+
19
+ async initializeTables() {
20
+ try {
21
+ await this.pool.query(`
22
+ -- Friend requests
23
+ CREATE TABLE IF NOT EXISTS friend_requests (
24
+ id SERIAL PRIMARY KEY,
25
+ from_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
26
+ to_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
27
+ status VARCHAR(20) NOT NULL CHECK (status IN ('pending', 'accepted', 'rejected')) DEFAULT 'pending',
28
+ created_at TIMESTAMP DEFAULT NOW(),
29
+ updated_at TIMESTAMP DEFAULT NOW()
30
+ );
31
+
32
+ -- Add unique constraint if it doesn't exist
33
+ DO $$
34
+ BEGIN
35
+ IF NOT EXISTS (
36
+ SELECT 1 FROM pg_constraint
37
+ WHERE conname = 'friend_requests_from_user_id_to_user_id_key'
38
+ ) THEN
39
+ ALTER TABLE friend_requests
40
+ ADD CONSTRAINT friend_requests_from_user_id_to_user_id_key
41
+ UNIQUE (from_user_id, to_user_id);
42
+ END IF;
43
+ END $$;
44
+
45
+ -- Groups (for future private rooms)
46
+ CREATE TABLE IF NOT EXISTS groups (
47
+ id SERIAL PRIMARY KEY,
48
+ name VARCHAR(100) NOT NULL,
49
+ created_by INTEGER REFERENCES users(id) ON DELETE CASCADE,
50
+ created_at TIMESTAMP DEFAULT NOW()
51
+ );
52
+
53
+ -- Group members
54
+ CREATE TABLE IF NOT EXISTS group_members (
55
+ id SERIAL PRIMARY KEY,
56
+ group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
57
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
58
+ joined_at TIMESTAMP DEFAULT NOW()
59
+ );
60
+
61
+ -- Add unique constraint for group_members if it doesn't exist
62
+ DO $$
63
+ BEGIN
64
+ IF NOT EXISTS (
65
+ SELECT 1 FROM pg_constraint
66
+ WHERE conname = 'group_members_group_id_user_id_key'
67
+ ) THEN
68
+ ALTER TABLE group_members
69
+ ADD CONSTRAINT group_members_group_id_user_id_key
70
+ UNIQUE (group_id, user_id);
71
+ END IF;
72
+ END $$;
73
+
74
+ -- Indexes
75
+ CREATE INDEX IF NOT EXISTS idx_friend_requests_from ON friend_requests(from_user_id);
76
+ CREATE INDEX IF NOT EXISTS idx_friend_requests_to ON friend_requests(to_user_id);
77
+ CREATE INDEX IF NOT EXISTS idx_friend_requests_status ON friend_requests(status);
78
+ CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
79
+ CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
80
+ `);
81
+ console.log('✅ Social/Friends tables initialized');
82
+ } catch (error) {
83
+ console.error('❌ Failed to initialize social tables:', error.message);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Search users by username
89
+ */
90
+ async searchUsers(query, currentUserId, limit = 1000) {
91
+ try {
92
+ const result = await this.pool.query(
93
+ `SELECT
94
+ u.id,
95
+ u.wallet_address,
96
+ u.username,
97
+ u.avatar,
98
+ COALESCE((SELECT COUNT(*) FROM user_game_refs ugr WHERE ugr.wallet_address = u.wallet_address), 0) as games_played,
99
+ EXISTS (
100
+ SELECT 1 FROM user_relationships
101
+ WHERE user_id = $1 AND target_user_id = u.id AND relationship_type = 'friend'
102
+ ) as is_friend,
103
+ EXISTS (
104
+ SELECT 1 FROM user_relationships
105
+ WHERE user_id = $1 AND target_user_id = u.id AND relationship_type = 'block'
106
+ ) as is_blocked,
107
+ EXISTS (
108
+ SELECT 1 FROM friend_requests
109
+ WHERE from_user_id = $1 AND to_user_id = u.id AND status = 'pending'
110
+ ) as friend_request_sent,
111
+ EXISTS (
112
+ SELECT 1 FROM friend_requests
113
+ WHERE from_user_id = u.id AND to_user_id = $1 AND status = 'pending'
114
+ ) as friend_request_received,
115
+ (
116
+ SELECT id FROM friend_requests
117
+ WHERE from_user_id = u.id AND to_user_id = $1 AND status = 'pending'
118
+ LIMIT 1
119
+ ) as incoming_friend_request_id
120
+ FROM users u
121
+ WHERE u.username ILIKE $2
122
+ AND u.id != $1
123
+ ORDER BY games_played DESC, u.created_at DESC
124
+ LIMIT $3`,
125
+ [currentUserId, `%${query}%`, limit]
126
+ );
127
+
128
+ return result.rows.map(row => ({
129
+ userId: row.id,
130
+ walletAddress: row.wallet_address,
131
+ username: row.username,
132
+ avatar: row.avatar,
133
+ isFriend: row.is_friend,
134
+ isBlocked: row.is_blocked,
135
+ friendRequestSent: row.friend_request_sent,
136
+ friendRequestReceived: row.friend_request_received,
137
+ friendRequestId: row.incoming_friend_request_id,
138
+ }));
139
+ } catch (error) {
140
+ console.error('Error searching users:', error);
141
+ return [];
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Send friend request
147
+ */
148
+ async sendFriendRequest(fromUserId, toUserId) {
149
+ try {
150
+ const result = await this.pool.query(
151
+ `INSERT INTO friend_requests (from_user_id, to_user_id, status, created_at)
152
+ VALUES ($1, $2, 'pending', NOW())
153
+ ON CONFLICT (from_user_id, to_user_id)
154
+ DO UPDATE SET status = 'pending', updated_at = NOW()
155
+ RETURNING id`,
156
+ [fromUserId, toUserId]
157
+ );
158
+
159
+ const requestId = result.rows[0].id;
160
+
161
+ // Create notification in database so it persists if user is offline
162
+ const notifResult = await this.pool.query(
163
+ `INSERT INTO chat_notifications (user_id, sender_user_id, notification_type, read, created_at)
164
+ VALUES ($1, $2, 'friend_request', false, NOW())
165
+ RETURNING id, created_at`,
166
+ [toUserId, fromUserId]
167
+ );
168
+
169
+ // Get sender info (username, wallet, avatar) for notifications
170
+ const senderResult = await this.pool.query(
171
+ 'SELECT username, wallet_address, avatar FROM users WHERE id = $1',
172
+ [fromUserId]
173
+ );
174
+ const senderUsername = senderResult.rows[0]?.username || 'Someone';
175
+ const senderWallet = senderResult.rows[0]?.wallet_address || '';
176
+ const senderAvatar = senderResult.rows[0]?.avatar || null;
177
+
178
+ // Forward to Telegram if connected
179
+ forwardChatNotification(this.pool, toUserId, 'friend_request', senderUsername).catch(err =>
180
+ console.error('[SocialService] Error forwarding friend_request notification to Telegram:', err.message)
181
+ );
182
+
183
+ // Cache notification to Redis (non-blocking)
184
+ const notificationId = notifResult.rows[0].id;
185
+ const notification = {
186
+ id: notificationId,
187
+ type: 'friend_request',
188
+ read: false,
189
+ message: '',
190
+ senderUsername,
191
+ senderWallet,
192
+ senderAvatar,
193
+ createdAt: notifResult.rows[0].created_at,
194
+ };
195
+ notificationCacheService.cacheNotification(toUserId, notification)
196
+ .catch(err => console.error('[SocialService] Failed to cache notification:', err.message));
197
+
198
+ return { requestId, notificationId };
199
+ } catch (error) {
200
+ console.error('Error sending friend request:', error);
201
+ return null;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Accept friend request
207
+ */
208
+ async acceptFriendRequest(requestId, userId) {
209
+ try {
210
+ // Update request status
211
+ const result = await this.pool.query(
212
+ `UPDATE friend_requests
213
+ SET status = 'accepted', updated_at = NOW()
214
+ WHERE id = $1 AND to_user_id = $2
215
+ RETURNING from_user_id, to_user_id`,
216
+ [requestId, userId]
217
+ );
218
+
219
+ if (result.rows.length === 0) return { success: false };
220
+
221
+ const { from_user_id, to_user_id } = result.rows[0];
222
+
223
+ // Create bidirectional friendship in user_relationships
224
+ await this.pool.query(
225
+ `INSERT INTO user_relationships (user_id, target_user_id, relationship_type)
226
+ VALUES ($1, $2, 'friend'), ($2, $1, 'friend')
227
+ ON CONFLICT (user_id, target_user_id)
228
+ DO UPDATE SET relationship_type = 'friend'`,
229
+ [from_user_id, to_user_id]
230
+ );
231
+
232
+ // Create notification for sender (their request was accepted!)
233
+ const notifResult = await this.pool.query(
234
+ `INSERT INTO chat_notifications (user_id, sender_user_id, notification_type, read, created_at)
235
+ VALUES ($1, $2, 'friend_request_accepted', false, NOW())
236
+ RETURNING id, created_at`,
237
+ [from_user_id, to_user_id]
238
+ );
239
+
240
+ // Get accepter info (username, wallet, avatar) for notifications
241
+ const accepterResult = await this.pool.query(
242
+ 'SELECT username, wallet_address, avatar FROM users WHERE id = $1',
243
+ [to_user_id]
244
+ );
245
+ const accepterUsername = accepterResult.rows[0]?.username || 'Someone';
246
+ const accepterWallet = accepterResult.rows[0]?.wallet_address || '';
247
+ const accepterAvatar = accepterResult.rows[0]?.avatar || null;
248
+
249
+ // Forward to Telegram if connected
250
+ forwardChatNotification(this.pool, from_user_id, 'friend_request_accepted', accepterUsername).catch(err =>
251
+ console.error('[SocialService] Error forwarding friend_request_accepted notification to Telegram:', err.message)
252
+ );
253
+
254
+ // Cache notification to Redis (non-blocking)
255
+ const notificationId = notifResult.rows[0].id;
256
+ const notification = {
257
+ id: notificationId,
258
+ type: 'friend_request_accepted',
259
+ read: false,
260
+ message: '',
261
+ senderUsername: accepterUsername,
262
+ senderWallet: accepterWallet,
263
+ senderAvatar: accepterAvatar,
264
+ createdAt: notifResult.rows[0].created_at,
265
+ };
266
+ notificationCacheService.cacheNotification(from_user_id, notification)
267
+ .catch(err => console.error('[SocialService] Failed to cache notification:', err.message));
268
+
269
+ return {
270
+ success: true,
271
+ fromUserId: from_user_id,
272
+ notificationId
273
+ };
274
+ } catch (error) {
275
+ console.error('Error accepting friend request:', error);
276
+ return { success: false };
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Reject friend request
282
+ */
283
+ async rejectFriendRequest(requestId, userId) {
284
+ try {
285
+ const result = await this.pool.query(
286
+ `UPDATE friend_requests
287
+ SET status = 'rejected', updated_at = NOW()
288
+ WHERE id = $1 AND to_user_id = $2
289
+ RETURNING id, from_user_id`,
290
+ [requestId, userId]
291
+ );
292
+ console.log('[SocialService] Reject query result:', result.rowCount, 'rows affected');
293
+
294
+ if (result.rowCount > 0) {
295
+ const fromUserId = result.rows[0].from_user_id;
296
+
297
+ // Create notification for sender (their request was declined)
298
+ const notifResult = await this.pool.query(
299
+ `INSERT INTO chat_notifications (user_id, sender_user_id, notification_type, read, created_at)
300
+ VALUES ($1, $2, 'friend_request_declined', false, NOW())
301
+ RETURNING id, created_at`,
302
+ [fromUserId, userId]
303
+ );
304
+
305
+ // Get decliner info (username, wallet, avatar) for notifications
306
+ const declinerResult = await this.pool.query(
307
+ 'SELECT username, wallet_address, avatar FROM users WHERE id = $1',
308
+ [userId]
309
+ );
310
+ const declinerUsername = declinerResult.rows[0]?.username || 'Someone';
311
+ const declinerWallet = declinerResult.rows[0]?.wallet_address || '';
312
+ const declinerAvatar = declinerResult.rows[0]?.avatar || null;
313
+
314
+ // Forward to Telegram if connected
315
+ forwardChatNotification(this.pool, fromUserId, 'friend_request_declined', declinerUsername).catch(err =>
316
+ console.error('[SocialService] Error forwarding friend_request_declined notification to Telegram:', err.message)
317
+ );
318
+
319
+ // Cache notification to Redis (non-blocking)
320
+ const notificationId = notifResult.rows[0].id;
321
+ const notification = {
322
+ id: notificationId,
323
+ type: 'friend_request_declined',
324
+ read: false,
325
+ message: '',
326
+ senderUsername: declinerUsername,
327
+ senderWallet: declinerWallet,
328
+ senderAvatar: declinerAvatar,
329
+ createdAt: notifResult.rows[0].created_at,
330
+ };
331
+ notificationCacheService.cacheNotification(fromUserId, notification)
332
+ .catch(err => console.error('[SocialService] Failed to cache notification:', err.message));
333
+
334
+ return {
335
+ success: true,
336
+ fromUserId: fromUserId,
337
+ notificationId
338
+ };
339
+ }
340
+ return { success: false };
341
+ } catch (error) {
342
+ console.error('Error rejecting friend request:', error);
343
+ return { success: false };
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Get pending friend requests (received)
349
+ */
350
+ async getPendingRequests(userId) {
351
+ try {
352
+ const result = await this.pool.query(
353
+ `SELECT
354
+ fr.id,
355
+ fr.from_user_id,
356
+ u.username as from_username,
357
+ u.avatar as from_avatar,
358
+ u.wallet_address as from_wallet,
359
+ fr.created_at
360
+ FROM friend_requests fr
361
+ JOIN users u ON fr.from_user_id = u.id
362
+ WHERE fr.to_user_id = $1 AND fr.status = 'pending'
363
+ ORDER BY fr.created_at DESC`,
364
+ [userId]
365
+ );
366
+
367
+ return result.rows.map(row => ({
368
+ id: row.id,
369
+ fromUserId: row.from_user_id,
370
+ fromUsername: row.from_username,
371
+ fromAvatar: row.from_avatar,
372
+ fromWallet: row.from_wallet,
373
+ createdAt: row.created_at,
374
+ }));
375
+ } catch (error) {
376
+ console.error('Error getting pending requests:', error);
377
+ return [];
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Get friends list
383
+ */
384
+ async getFriends(userId) {
385
+ try {
386
+ const result = await this.pool.query(
387
+ `SELECT
388
+ u.id,
389
+ u.wallet_address,
390
+ u.username,
391
+ u.avatar,
392
+ ur.created_at as friends_since
393
+ FROM user_relationships ur
394
+ JOIN users u ON ur.target_user_id = u.id
395
+ WHERE ur.user_id = $1 AND ur.relationship_type = 'friend'
396
+ ORDER BY ur.created_at DESC`,
397
+ [userId]
398
+ );
399
+
400
+ return result.rows.map(row => ({
401
+ userId: row.id,
402
+ walletAddress: row.wallet_address,
403
+ username: row.username,
404
+ avatar: row.avatar,
405
+ friendsSince: row.friends_since,
406
+ }));
407
+ } catch (error) {
408
+ console.error('Error getting friends:', error);
409
+ return [];
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Remove friend
415
+ */
416
+ async removeFriend(userId, targetUserId) {
417
+ try {
418
+ // Remove bidirectional friendship
419
+ const result = await this.pool.query(
420
+ `DELETE FROM user_relationships
421
+ WHERE (user_id = $1 AND target_user_id = $2 AND relationship_type = 'friend')
422
+ OR (user_id = $2 AND target_user_id = $1 AND relationship_type = 'friend')
423
+ RETURNING user_id, target_user_id`,
424
+ [userId, targetUserId]
425
+ );
426
+ return { success: result.rowCount > 0, targetUserId };
427
+ } catch (error) {
428
+ console.error('Error removing friend:', error);
429
+ return { success: false };
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Get blocked users
435
+ */
436
+ async getBlockedUsers(userId) {
437
+ try {
438
+ const result = await this.pool.query(
439
+ `SELECT
440
+ u.id,
441
+ u.wallet_address,
442
+ u.username,
443
+ u.avatar,
444
+ ur.created_at as blocked_at
445
+ FROM user_relationships ur
446
+ JOIN users u ON ur.target_user_id = u.id
447
+ WHERE ur.user_id = $1 AND ur.relationship_type = 'block'
448
+ ORDER BY ur.created_at DESC`,
449
+ [userId]
450
+ );
451
+
452
+ return result.rows.map(row => ({
453
+ userId: row.id,
454
+ walletAddress: row.wallet_address,
455
+ username: row.username,
456
+ avatar: row.avatar,
457
+ blockedAt: row.blocked_at,
458
+ }));
459
+ } catch (error) {
460
+ console.error('Error getting blocked users:', error);
461
+ return [];
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Get top players by wins for each category
467
+ * Calculates wins directly from games table (not sports_betting_stats which may be empty)
468
+ * @param {number} currentUserId - Current user's ID
469
+ * @param {number} limit - Number of top players per category
470
+ */
471
+ async getTopPlayers(currentUserId, limit = 10) {
472
+ try {
473
+ // Get top sports players by wins - calculated from games table
474
+ const sportsResult = await this.pool.query(`
475
+ WITH sports_stats AS (
476
+ SELECT
477
+ u.id as user_id,
478
+ u.wallet_address,
479
+ u.username,
480
+ u.avatar,
481
+ COUNT(*) as total_games,
482
+ SUM(CASE WHEN ugr.team_choice = g.sports_event->'finalScore'->>'winner' THEN 1 ELSE 0 END) as wins,
483
+ SUM(CASE WHEN ugr.team_choice != g.sports_event->'finalScore'->>'winner' THEN 1 ELSE 0 END) as losses
484
+ FROM users u
485
+ JOIN user_game_refs ugr ON ugr.wallet_address = u.wallet_address
486
+ JOIN games g ON g.game_id = ugr.game_id
487
+ WHERE g.game_type = 'sports'
488
+ AND g.is_resolved = true
489
+ AND g.sports_event->'finalScore' IS NOT NULL
490
+ AND u.id != $1
491
+ GROUP BY u.id, u.wallet_address, u.username, u.avatar
492
+ )
493
+ SELECT
494
+ user_id,
495
+ wallet_address,
496
+ username,
497
+ avatar,
498
+ wins,
499
+ total_games,
500
+ CASE WHEN (wins + losses) > 0
501
+ THEN ROUND((wins::numeric / (wins + losses)) * 100, 1)
502
+ ELSE 0
503
+ END as win_rate,
504
+ EXISTS (
505
+ SELECT 1 FROM user_relationships
506
+ WHERE user_id = $1 AND target_user_id = sports_stats.user_id AND relationship_type = 'friend'
507
+ ) as is_friend,
508
+ EXISTS (
509
+ SELECT 1 FROM friend_requests
510
+ WHERE from_user_id = $1 AND to_user_id = sports_stats.user_id AND status = 'pending'
511
+ ) as friend_request_sent
512
+ FROM sports_stats
513
+ WHERE wins > 0 AND (wins + losses) >= 3
514
+ ORDER BY win_rate DESC, wins DESC
515
+ LIMIT $2
516
+ `, [currentUserId, limit]);
517
+
518
+ // Get top connect4 players by wins - calculated from games table using team_choice
519
+ console.log('[TopPlayers] Fetching connect4 top players, excluding user:', currentUserId);
520
+ const connect4Result = await this.pool.query(`
521
+ WITH connect4_stats AS (
522
+ SELECT
523
+ u.id as user_id,
524
+ u.wallet_address,
525
+ u.username,
526
+ u.avatar,
527
+ COUNT(DISTINCT g.game_id) as total_games,
528
+ SUM(CASE WHEN g.game_status = 'completed' AND g.connect4_winner IN ('home', 'away') AND ugr.team_choice = g.connect4_winner THEN 1 ELSE 0 END) as wins,
529
+ SUM(CASE WHEN g.game_status = 'completed' AND g.connect4_winner IN ('home', 'away') AND ugr.team_choice != g.connect4_winner THEN 1 ELSE 0 END) as losses
530
+ FROM users u
531
+ JOIN user_game_refs ugr ON ugr.wallet_address = u.wallet_address
532
+ JOIN games g ON g.game_id = ugr.game_id
533
+ WHERE g.game_type = 'connect4'
534
+ AND u.id != $1
535
+ GROUP BY u.id, u.wallet_address, u.username, u.avatar
536
+ )
537
+ SELECT
538
+ user_id,
539
+ wallet_address,
540
+ username,
541
+ avatar,
542
+ wins,
543
+ total_games,
544
+ CASE WHEN (wins + losses) > 0
545
+ THEN ROUND((wins::numeric / (wins + losses)) * 100, 1)
546
+ ELSE 0
547
+ END as win_rate,
548
+ EXISTS (
549
+ SELECT 1 FROM user_relationships
550
+ WHERE user_id = $1 AND target_user_id = connect4_stats.user_id AND relationship_type = 'friend'
551
+ ) as is_friend,
552
+ EXISTS (
553
+ SELECT 1 FROM friend_requests
554
+ WHERE from_user_id = $1 AND to_user_id = connect4_stats.user_id AND status = 'pending'
555
+ ) as friend_request_sent
556
+ FROM connect4_stats
557
+ WHERE wins > 0
558
+ ORDER BY wins DESC, win_rate DESC, total_games DESC
559
+ LIMIT $2
560
+ `, [currentUserId, limit]);
561
+
562
+ console.log('[TopPlayers] Connect4 results:', connect4Result.rows.length, 'players found');
563
+ if (connect4Result.rows.length > 0) {
564
+ console.log('[TopPlayers] Top connect4 player:', connect4Result.rows[0].username, 'with', connect4Result.rows[0].wins, 'wins');
565
+ }
566
+
567
+ const mapPlayer = (row, rank) => ({
568
+ rank: rank + 1,
569
+ userId: row.user_id,
570
+ walletAddress: row.wallet_address,
571
+ username: row.username,
572
+ avatar: row.avatar,
573
+ wins: parseInt(row.wins),
574
+ totalGames: parseInt(row.total_games),
575
+ winRate: parseFloat(row.win_rate),
576
+ isFriend: row.is_friend,
577
+ friendRequestSent: row.friend_request_sent,
578
+ });
579
+
580
+ return {
581
+ sports: sportsResult.rows.map(mapPlayer),
582
+ connect4: connect4Result.rows.map(mapPlayer),
583
+ };
584
+ } catch (error) {
585
+ console.error('Error getting top players:', error);
586
+ return { sports: [], connect4: [] };
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Get category summary for discover page
592
+ * Returns user counts for each game category plus "all" for users without game history
593
+ */
594
+ async getCategorySummary(currentUserId) {
595
+ try {
596
+ // Get game-based category counts
597
+ const categoryResult = await this.pool.query(`
598
+ WITH category_counts AS (
599
+ SELECT
600
+ CASE
601
+ WHEN g.game_type = 'connect4' THEN 'connect4'
602
+ WHEN g.sports_event->>'strLeague' = 'NBA' THEN 'NBA'
603
+ WHEN g.sports_event->>'strLeague' = 'NHL' THEN 'NHL'
604
+ WHEN g.sports_event->>'strLeague' = 'NFL' THEN 'NFL'
605
+ WHEN g.sports_event->>'strLeague' = 'UFC' THEN 'UFC'
606
+ WHEN g.sports_event->>'strLeague' = 'English Premier League' THEN 'EPL'
607
+ ELSE NULL
608
+ END as category,
609
+ COUNT(DISTINCT ugr.wallet_address) as user_count
610
+ FROM user_game_refs ugr
611
+ JOIN games g ON g.game_id = ugr.game_id
612
+ WHERE g.game_type IN ('sports', 'connect4')
613
+ GROUP BY
614
+ CASE
615
+ WHEN g.game_type = 'connect4' THEN 'connect4'
616
+ WHEN g.sports_event->>'strLeague' = 'NBA' THEN 'NBA'
617
+ WHEN g.sports_event->>'strLeague' = 'NHL' THEN 'NHL'
618
+ WHEN g.sports_event->>'strLeague' = 'NFL' THEN 'NFL'
619
+ WHEN g.sports_event->>'strLeague' = 'UFC' THEN 'UFC'
620
+ WHEN g.sports_event->>'strLeague' = 'English Premier League' THEN 'EPL'
621
+ ELSE NULL
622
+ END
623
+ )
624
+ SELECT category, user_count
625
+ FROM category_counts
626
+ WHERE category IS NOT NULL
627
+ ORDER BY user_count DESC
628
+ `);
629
+
630
+ // Get count of users who haven't played any games (new/inactive users)
631
+ const newUsersResult = await this.pool.query(`
632
+ SELECT COUNT(*) as user_count
633
+ FROM users u
634
+ WHERE u.id != $1
635
+ AND NOT EXISTS (
636
+ SELECT 1 FROM user_game_refs ugr WHERE ugr.wallet_address = u.wallet_address
637
+ )
638
+ `, [currentUserId || 0]);
639
+
640
+ const categoryMeta = {
641
+ NBA: { name: 'NBA', icon: 'basketball', order: 1 },
642
+ NHL: { name: 'NHL', icon: 'hockey', order: 2 },
643
+ NFL: { name: 'NFL', icon: 'football', order: 3 },
644
+ EPL: { name: 'Soccer', icon: 'soccer', order: 4 },
645
+ UFC: { name: 'UFC', icon: 'fighting', order: 5 },
646
+ connect4: { name: 'Connect4', icon: 'connect4', order: 6 },
647
+ all: { name: 'New Users', icon: 'users', order: 7 },
648
+ };
649
+
650
+ const categories = categoryResult.rows
651
+ .filter(row => categoryMeta[row.category])
652
+ .map(row => ({
653
+ id: row.category,
654
+ name: categoryMeta[row.category].name,
655
+ icon: categoryMeta[row.category].icon,
656
+ userCount: parseInt(row.user_count),
657
+ order: categoryMeta[row.category].order,
658
+ }));
659
+
660
+ // Add "New Users" category if there are users without game history
661
+ const newUsersCount = parseInt(newUsersResult.rows[0].user_count);
662
+ if (newUsersCount > 0) {
663
+ categories.push({
664
+ id: 'all',
665
+ name: categoryMeta.all.name,
666
+ icon: categoryMeta.all.icon,
667
+ userCount: newUsersCount,
668
+ order: categoryMeta.all.order,
669
+ });
670
+ }
671
+
672
+ return categories.sort((a, b) => a.order - b.order);
673
+ } catch (error) {
674
+ console.error('Error getting category summary:', error);
675
+ return [];
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Discover users by category with pagination
681
+ * @param {string} category - NBA, NHL, NFL, EPL, UFC, connect4, or "all" (new users without game history)
682
+ * @param {number} currentUserId - Current user's ID (to exclude and check relationships)
683
+ * @param {object} options - { page, limit, sortBy }
684
+ */
685
+ async discoverByCategory(category, currentUserId, options = {}) {
686
+ const { page = 1, limit = 20, sortBy = 'games_played' } = options;
687
+ const offset = (page - 1) * limit;
688
+ const safeLimit = Math.min(Math.max(1, limit), 50); // Cap at 50
689
+
690
+ // Handle "all" category - users without game history (new/inactive users)
691
+ if (category === 'all') {
692
+ return this.discoverNewUsers(currentUserId, { page, limit: safeLimit, sortBy });
693
+ }
694
+
695
+ // Handle Connect4 separately - wins calculated from games table, not sports_betting_stats
696
+ if (category === 'connect4') {
697
+ return this.discoverConnect4Users(currentUserId, { page, limit: safeLimit, sortBy });
698
+ }
699
+
700
+ // Map category to SQL condition (sports only)
701
+ const categoryConditions = {
702
+ NBA: "g.sports_event->>'strLeague' = 'NBA'",
703
+ NHL: "g.sports_event->>'strLeague' = 'NHL'",
704
+ NFL: "g.sports_event->>'strLeague' = 'NFL'",
705
+ EPL: "g.sports_event->>'strLeague' = 'English Premier League'",
706
+ UFC: "g.sports_event->>'strLeague' = 'UFC'",
707
+ };
708
+
709
+ const categoryCondition = categoryConditions[category];
710
+ if (!categoryCondition) {
711
+ return { users: [], pagination: { page, limit: safeLimit, total: 0, totalPages: 0, hasMore: false } };
712
+ }
713
+
714
+ try {
715
+ // Get total count first
716
+ const countResult = await this.pool.query(`
717
+ SELECT COUNT(DISTINCT ugr.wallet_address) as total
718
+ FROM user_game_refs ugr
719
+ JOIN games g ON g.game_id = ugr.game_id
720
+ JOIN users u ON u.wallet_address = ugr.wallet_address
721
+ WHERE ${categoryCondition}
722
+ AND u.id != $1
723
+ `, [currentUserId]);
724
+
725
+ const total = parseInt(countResult.rows[0].total);
726
+ const totalPages = Math.ceil(total / safeLimit);
727
+
728
+ // Get paginated users with relationship info
729
+ // Sort by: wins first (top players), then win rate, then games_played
730
+ const result = await this.pool.query(`
731
+ WITH category_users AS (
732
+ SELECT
733
+ u.id as user_id,
734
+ u.wallet_address,
735
+ u.username,
736
+ u.avatar,
737
+ u.created_at,
738
+ COUNT(DISTINCT ugr.game_id) as games_played,
739
+ MAX(ugr.joined_at) as last_played_at
740
+ FROM users u
741
+ JOIN user_game_refs ugr ON ugr.wallet_address = u.wallet_address
742
+ JOIN games g ON g.game_id = ugr.game_id
743
+ WHERE ${categoryCondition}
744
+ AND u.id != $1
745
+ GROUP BY u.id, u.wallet_address, u.username, u.avatar, u.created_at
746
+ )
747
+ SELECT
748
+ cu.*,
749
+ COALESCE(sbs.games_won, 0) as wins,
750
+ CASE WHEN COALESCE(sbs.games_won, 0) + COALESCE(sbs.games_lost, 0) > 0
751
+ THEN ROUND((COALESCE(sbs.games_won, 0)::numeric / (COALESCE(sbs.games_won, 0) + COALESCE(sbs.games_lost, 0))) * 100, 1)
752
+ ELSE 0
753
+ END as win_rate,
754
+ EXISTS (
755
+ SELECT 1 FROM user_relationships
756
+ WHERE user_id = $1 AND target_user_id = cu.user_id AND relationship_type = 'friend'
757
+ ) as is_friend,
758
+ EXISTS (
759
+ SELECT 1 FROM user_relationships
760
+ WHERE user_id = $1 AND target_user_id = cu.user_id AND relationship_type = 'block'
761
+ ) as is_blocked,
762
+ EXISTS (
763
+ SELECT 1 FROM friend_requests
764
+ WHERE from_user_id = $1 AND to_user_id = cu.user_id AND status = 'pending'
765
+ ) as friend_request_sent,
766
+ EXISTS (
767
+ SELECT 1 FROM friend_requests
768
+ WHERE from_user_id = cu.user_id AND to_user_id = $1 AND status = 'pending'
769
+ ) as friend_request_received,
770
+ (
771
+ SELECT id FROM friend_requests
772
+ WHERE from_user_id = cu.user_id AND to_user_id = $1 AND status = 'pending'
773
+ LIMIT 1
774
+ ) as incoming_friend_request_id
775
+ FROM category_users cu
776
+ LEFT JOIN sports_betting_stats sbs ON sbs.wallet_address = cu.wallet_address
777
+ ORDER BY COALESCE(sbs.games_won, 0) DESC, win_rate DESC, games_played DESC
778
+ LIMIT $2 OFFSET $3
779
+ `, [currentUserId, safeLimit, offset]);
780
+
781
+ const users = result.rows.map(row => ({
782
+ userId: row.user_id,
783
+ walletAddress: row.wallet_address,
784
+ username: row.username,
785
+ avatar: row.avatar,
786
+ gamesPlayed: parseInt(row.games_played),
787
+ wins: parseInt(row.wins) || 0,
788
+ winRate: parseFloat(row.win_rate) || 0,
789
+ lastPlayedAt: row.last_played_at,
790
+ isFriend: row.is_friend,
791
+ isBlocked: row.is_blocked,
792
+ friendRequestSent: row.friend_request_sent,
793
+ friendRequestReceived: row.friend_request_received,
794
+ friendRequestId: row.incoming_friend_request_id,
795
+ }));
796
+
797
+ return {
798
+ users,
799
+ pagination: {
800
+ page,
801
+ limit: safeLimit,
802
+ total,
803
+ totalPages,
804
+ hasMore: page < totalPages,
805
+ },
806
+ };
807
+ } catch (error) {
808
+ console.error('Error discovering users by category:', error);
809
+ return { users: [], pagination: { page, limit: safeLimit, total: 0, totalPages: 0, hasMore: false } };
810
+ }
811
+ }
812
+
813
+ /**
814
+ * Discover Connect4 users with proper win/loss calculation from games table
815
+ * Sorted by: win_rate DESC (100% first), then wins DESC, then games_played DESC
816
+ */
817
+ async discoverConnect4Users(currentUserId, options = {}) {
818
+ const { page = 1, limit = 20 } = options;
819
+ const offset = (page - 1) * limit;
820
+ const safeLimit = Math.min(Math.max(1, limit), 50);
821
+
822
+ try {
823
+ // Get total count
824
+ const countResult = await this.pool.query(`
825
+ SELECT COUNT(DISTINCT ugr.wallet_address) as total
826
+ FROM user_game_refs ugr
827
+ JOIN games g ON g.game_id = ugr.game_id
828
+ JOIN users u ON u.wallet_address = ugr.wallet_address
829
+ WHERE g.game_type = 'connect4'
830
+ AND u.id != $1
831
+ `, [currentUserId]);
832
+
833
+ const total = parseInt(countResult.rows[0].total);
834
+ const totalPages = Math.ceil(total / safeLimit);
835
+
836
+ // Get Connect4 users with wins/losses calculated from games table
837
+ // Sort by: win_rate DESC (players with 100% win rate first), then wins, then games played
838
+ const result = await this.pool.query(`
839
+ WITH connect4_stats AS (
840
+ SELECT
841
+ u.id as user_id,
842
+ u.wallet_address,
843
+ u.username,
844
+ u.avatar,
845
+ u.created_at,
846
+ COUNT(DISTINCT g.game_id) as games_played,
847
+ SUM(CASE WHEN g.game_status = 'completed' AND g.connect4_winner IN ('home', 'away') AND ugr.team_choice = g.connect4_winner THEN 1 ELSE 0 END) as wins,
848
+ SUM(CASE WHEN g.game_status = 'completed' AND g.connect4_winner IN ('home', 'away') AND ugr.team_choice != g.connect4_winner THEN 1 ELSE 0 END) as losses,
849
+ MAX(ugr.joined_at) as last_played_at
850
+ FROM users u
851
+ JOIN user_game_refs ugr ON ugr.wallet_address = u.wallet_address
852
+ JOIN games g ON g.game_id = ugr.game_id
853
+ WHERE g.game_type = 'connect4'
854
+ AND u.id != $1
855
+ GROUP BY u.id, u.wallet_address, u.username, u.avatar, u.created_at
856
+ )
857
+ SELECT
858
+ cs.*,
859
+ CASE WHEN (cs.wins + cs.losses) > 0
860
+ THEN ROUND((cs.wins::numeric / (cs.wins + cs.losses)) * 100, 1)
861
+ ELSE 0
862
+ END as win_rate,
863
+ EXISTS (
864
+ SELECT 1 FROM user_relationships
865
+ WHERE user_id = $1 AND target_user_id = cs.user_id AND relationship_type = 'friend'
866
+ ) as is_friend,
867
+ EXISTS (
868
+ SELECT 1 FROM user_relationships
869
+ WHERE user_id = $1 AND target_user_id = cs.user_id AND relationship_type = 'block'
870
+ ) as is_blocked,
871
+ EXISTS (
872
+ SELECT 1 FROM friend_requests
873
+ WHERE from_user_id = $1 AND to_user_id = cs.user_id AND status = 'pending'
874
+ ) as friend_request_sent,
875
+ EXISTS (
876
+ SELECT 1 FROM friend_requests
877
+ WHERE from_user_id = cs.user_id AND to_user_id = $1 AND status = 'pending'
878
+ ) as friend_request_received,
879
+ (
880
+ SELECT id FROM friend_requests
881
+ WHERE from_user_id = cs.user_id AND to_user_id = $1 AND status = 'pending'
882
+ LIMIT 1
883
+ ) as incoming_friend_request_id
884
+ FROM connect4_stats cs
885
+ ORDER BY
886
+ CASE WHEN (cs.wins + cs.losses) >= 3 THEN 1 ELSE 2 END,
887
+ win_rate DESC,
888
+ wins DESC,
889
+ games_played DESC
890
+ LIMIT $2 OFFSET $3
891
+ `, [currentUserId, safeLimit, offset]);
892
+
893
+ const users = result.rows.map(row => ({
894
+ userId: row.user_id,
895
+ walletAddress: row.wallet_address,
896
+ username: row.username,
897
+ avatar: row.avatar,
898
+ gamesPlayed: parseInt(row.games_played),
899
+ wins: parseInt(row.wins) || 0,
900
+ winRate: parseFloat(row.win_rate) || 0,
901
+ lastPlayedAt: row.last_played_at,
902
+ isFriend: row.is_friend,
903
+ isBlocked: row.is_blocked,
904
+ friendRequestSent: row.friend_request_sent,
905
+ friendRequestReceived: row.friend_request_received,
906
+ friendRequestId: row.incoming_friend_request_id,
907
+ }));
908
+
909
+ return {
910
+ users,
911
+ pagination: {
912
+ page,
913
+ limit: safeLimit,
914
+ total,
915
+ totalPages,
916
+ hasMore: page < totalPages,
917
+ },
918
+ };
919
+ } catch (error) {
920
+ console.error('Error discovering Connect4 users:', error);
921
+ return { users: [], pagination: { page, limit: safeLimit, total: 0, totalPages: 0, hasMore: false } };
922
+ }
923
+ }
924
+
925
+ /**
926
+ * Discover new/inactive users who haven't played any games yet
927
+ * @param {number} currentUserId - Current user's ID
928
+ * @param {object} options - { page, limit, sortBy }
929
+ */
930
+ async discoverNewUsers(currentUserId, options = {}) {
931
+ const { page = 1, limit = 20, sortBy = 'recent' } = options;
932
+ const offset = (page - 1) * limit;
933
+ const safeLimit = Math.min(Math.max(1, limit), 50);
934
+
935
+ // Sort by created_at for new users
936
+ const sortOrders = {
937
+ recent: 'u.created_at DESC',
938
+ activity: 'u.created_at DESC', // Fallback to recent for new users
939
+ };
940
+ const orderBy = sortOrders[sortBy] || sortOrders.recent;
941
+
942
+ try {
943
+ // Get total count of users without game history
944
+ const countResult = await this.pool.query(`
945
+ SELECT COUNT(*) as total
946
+ FROM users u
947
+ WHERE u.id != $1
948
+ AND NOT EXISTS (
949
+ SELECT 1 FROM user_game_refs ugr WHERE ugr.wallet_address = u.wallet_address
950
+ )
951
+ `, [currentUserId]);
952
+
953
+ const total = parseInt(countResult.rows[0].total);
954
+ const totalPages = Math.ceil(total / safeLimit);
955
+
956
+ // Get paginated new users with relationship info
957
+ const result = await this.pool.query(`
958
+ SELECT
959
+ u.id as user_id,
960
+ u.wallet_address,
961
+ u.username,
962
+ u.avatar,
963
+ u.created_at,
964
+ 0 as games_played,
965
+ NULL as last_played_at,
966
+ EXISTS (
967
+ SELECT 1 FROM user_relationships
968
+ WHERE user_id = $1 AND target_user_id = u.id AND relationship_type = 'friend'
969
+ ) as is_friend,
970
+ EXISTS (
971
+ SELECT 1 FROM user_relationships
972
+ WHERE user_id = $1 AND target_user_id = u.id AND relationship_type = 'block'
973
+ ) as is_blocked,
974
+ EXISTS (
975
+ SELECT 1 FROM friend_requests
976
+ WHERE from_user_id = $1 AND to_user_id = u.id AND status = 'pending'
977
+ ) as friend_request_sent,
978
+ EXISTS (
979
+ SELECT 1 FROM friend_requests
980
+ WHERE from_user_id = u.id AND to_user_id = $1 AND status = 'pending'
981
+ ) as friend_request_received,
982
+ (
983
+ SELECT id FROM friend_requests
984
+ WHERE from_user_id = u.id AND to_user_id = $1 AND status = 'pending'
985
+ LIMIT 1
986
+ ) as incoming_friend_request_id
987
+ FROM users u
988
+ WHERE u.id != $1
989
+ AND NOT EXISTS (
990
+ SELECT 1 FROM user_game_refs ugr WHERE ugr.wallet_address = u.wallet_address
991
+ )
992
+ ORDER BY ${orderBy}
993
+ LIMIT $2 OFFSET $3
994
+ `, [currentUserId, safeLimit, offset]);
995
+
996
+ const users = result.rows.map(row => ({
997
+ userId: row.user_id,
998
+ walletAddress: row.wallet_address,
999
+ username: row.username,
1000
+ avatar: row.avatar,
1001
+ gamesPlayed: 0,
1002
+ lastPlayedAt: null,
1003
+ isFriend: row.is_friend,
1004
+ isBlocked: row.is_blocked,
1005
+ friendRequestSent: row.friend_request_sent,
1006
+ friendRequestReceived: row.friend_request_received,
1007
+ friendRequestId: row.incoming_friend_request_id,
1008
+ }));
1009
+
1010
+ return {
1011
+ users,
1012
+ pagination: {
1013
+ page,
1014
+ limit: safeLimit,
1015
+ total,
1016
+ totalPages,
1017
+ hasMore: page < totalPages,
1018
+ },
1019
+ };
1020
+ } catch (error) {
1021
+ console.error('Error discovering new users:', error);
1022
+ return { users: [], pagination: { page, limit: safeLimit, total: 0, totalPages: 0, hasMore: false } };
1023
+ }
1024
+ }
1025
+ /**
1026
+ * Get leaderboard for the modal with multiple tab perspectives
1027
+ * @param {string} gameType - connect4 | sports
1028
+ * @param {string} tab - wins | winrate | earnings | grinder | efficiency
1029
+ * @param {number} limit - max players to return (default 25, max 50)
1030
+ */
1031
+ async getLeaderboard(gameType = 'connect4', tab = 'wins', limit = 25) {
1032
+ const safeLimit = Math.min(Math.max(1, parseInt(limit) || 25), 50);
1033
+ const validTabs = ['wins', 'winrate', 'earnings', 'grinder', 'efficiency'];
1034
+ const safeTab = validTabs.includes(tab) ? tab : 'wins';
1035
+ const safeGameType = ['sports', 'esports', 'connect4'].includes(gameType) ? gameType : 'connect4';
1036
+
1037
+ const tabConfig = {
1038
+ wins: {
1039
+ where: '',
1040
+ orderBy: 'wins DESC, win_rate DESC, games_played DESC',
1041
+ },
1042
+ winrate: {
1043
+ where: 'AND games_played >= 10',
1044
+ orderBy: 'win_rate DESC, games_played DESC',
1045
+ },
1046
+ earnings: {
1047
+ where: '',
1048
+ orderBy: 'total_claimed DESC, claim_per_game DESC',
1049
+ },
1050
+ grinder: {
1051
+ where: '',
1052
+ orderBy: 'games_played DESC, win_rate DESC',
1053
+ },
1054
+ efficiency: {
1055
+ where: 'AND games_played >= 5',
1056
+ orderBy: 'claim_per_game DESC, games_played DESC',
1057
+ },
1058
+ };
1059
+
1060
+ const config = tabConfig[safeTab];
1061
+
1062
+ // Build game-type-specific stats CTE
1063
+ let statsCTE;
1064
+ if (safeGameType === 'connect4') {
1065
+ statsCTE = `
1066
+ SELECT
1067
+ u.wallet_address,
1068
+ u.username,
1069
+ u.avatar,
1070
+ COUNT(*) AS games_played,
1071
+ COUNT(*) FILTER (
1072
+ WHERE g.game_status = 'completed'
1073
+ AND g.connect4_winner = ugr.team_choice
1074
+ ) AS wins,
1075
+ COUNT(*) FILTER (
1076
+ WHERE g.game_status = 'completed'
1077
+ AND g.connect4_winner IS NOT NULL
1078
+ AND g.connect4_winner != 'draw'
1079
+ AND g.connect4_winner != ugr.team_choice
1080
+ ) AS losses,
1081
+ COUNT(*) FILTER (
1082
+ WHERE g.game_status = 'completed'
1083
+ AND g.connect4_winner = 'draw'
1084
+ ) AS draws,
1085
+ COALESCE(SUM(ugr.amount_claimed), 0) AS total_claimed
1086
+ FROM user_game_refs ugr
1087
+ JOIN games g ON g.game_id = ugr.game_id
1088
+ LEFT JOIN users u ON u.wallet_address = ugr.wallet_address
1089
+ WHERE g.game_type = 'connect4'
1090
+ GROUP BY u.wallet_address, u.username, u.avatar
1091
+ `;
1092
+ } else if (safeGameType === 'esports') {
1093
+ // Esports: game_type='sports' + game_mode=5 (PandaScore matches)
1094
+ statsCTE = `
1095
+ SELECT
1096
+ u.wallet_address,
1097
+ u.username,
1098
+ u.avatar,
1099
+ COUNT(*) AS games_played,
1100
+ COUNT(*) FILTER (
1101
+ WHERE g.is_resolved = true
1102
+ AND g.sports_event->'finalScore' IS NOT NULL
1103
+ AND ugr.team_choice = g.sports_event->'finalScore'->>'winner'
1104
+ ) AS wins,
1105
+ COUNT(*) FILTER (
1106
+ WHERE g.is_resolved = true
1107
+ AND g.sports_event->'finalScore' IS NOT NULL
1108
+ AND ugr.team_choice != g.sports_event->'finalScore'->>'winner'
1109
+ ) AS losses,
1110
+ 0::bigint AS draws,
1111
+ COALESCE(SUM(ugr.amount_claimed), 0) AS total_claimed
1112
+ FROM user_game_refs ugr
1113
+ JOIN games g ON g.game_id = ugr.game_id
1114
+ LEFT JOIN users u ON u.wallet_address = ugr.wallet_address
1115
+ WHERE g.game_type = 'sports' AND g.game_mode = 5
1116
+ GROUP BY u.wallet_address, u.username, u.avatar
1117
+ `;
1118
+ } else {
1119
+ // Traditional sports: game_type='sports' + game_mode=4
1120
+ statsCTE = `
1121
+ SELECT
1122
+ u.wallet_address,
1123
+ u.username,
1124
+ u.avatar,
1125
+ COUNT(*) AS games_played,
1126
+ COUNT(*) FILTER (
1127
+ WHERE g.is_resolved = true
1128
+ AND g.sports_event->'finalScore' IS NOT NULL
1129
+ AND ugr.team_choice = g.sports_event->'finalScore'->>'winner'
1130
+ ) AS wins,
1131
+ COUNT(*) FILTER (
1132
+ WHERE g.is_resolved = true
1133
+ AND g.sports_event->'finalScore' IS NOT NULL
1134
+ AND ugr.team_choice != g.sports_event->'finalScore'->>'winner'
1135
+ ) AS losses,
1136
+ 0::bigint AS draws,
1137
+ COALESCE(SUM(ugr.amount_claimed), 0) AS total_claimed
1138
+ FROM user_game_refs ugr
1139
+ JOIN games g ON g.game_id = ugr.game_id
1140
+ LEFT JOIN users u ON u.wallet_address = ugr.wallet_address
1141
+ WHERE g.game_type = 'sports' AND g.game_mode = 4
1142
+ GROUP BY u.wallet_address, u.username, u.avatar
1143
+ `;
1144
+ }
1145
+
1146
+ try {
1147
+ const result = await this.pool.query(`
1148
+ WITH stats AS (
1149
+ ${statsCTE}
1150
+ ),
1151
+ ranked AS (
1152
+ SELECT
1153
+ wallet_address,
1154
+ username,
1155
+ avatar,
1156
+ games_played,
1157
+ wins,
1158
+ losses,
1159
+ draws,
1160
+ total_claimed,
1161
+ CASE WHEN games_played > 0
1162
+ THEN ROUND((wins::numeric / games_played) * 100, 1)
1163
+ ELSE 0
1164
+ END AS win_rate,
1165
+ CASE WHEN games_played > 0
1166
+ THEN ROUND(total_claimed::numeric / games_played, 4)
1167
+ ELSE 0
1168
+ END AS claim_per_game
1169
+ FROM stats
1170
+ WHERE games_played > 0
1171
+ ${config.where}
1172
+ )
1173
+ SELECT *
1174
+ FROM ranked
1175
+ ORDER BY ${config.orderBy}
1176
+ LIMIT $1
1177
+ `, [safeLimit]);
1178
+
1179
+ const leaderboard = result.rows.map((row, i) => ({
1180
+ rank: i + 1,
1181
+ walletAddress: row.wallet_address,
1182
+ username: row.username || null,
1183
+ avatar: row.avatar || null,
1184
+ gamesPlayed: parseInt(row.games_played),
1185
+ wins: parseInt(row.wins),
1186
+ losses: parseInt(row.losses),
1187
+ draws: parseInt(row.draws),
1188
+ winRate: parseFloat(row.win_rate),
1189
+ totalClaimed: parseFloat(row.total_claimed),
1190
+ claimPerGame: parseFloat(row.claim_per_game),
1191
+ }));
1192
+
1193
+ return { gameType: safeGameType, tab: safeTab, leaderboard };
1194
+ } catch (error) {
1195
+ console.error(`Error getting ${safeGameType} leaderboard:`, error);
1196
+ return { gameType: safeGameType, tab: safeTab, leaderboard: [] };
1197
+ }
1198
+ }
1199
+ }
1200
+
1201
+ module.exports = SocialService;
1202
+