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