From 6e66d2331361cca559156299e614afca206c50c1 Mon Sep 17 00:00:00 2001 From: NotSoToothless <67082114+FURRO404@users.noreply.github.com> Date: Thu, 14 May 2026 14:21:58 -0700 Subject: [PATCH] add TSS API endpoints + web proxy (#1234) Adds /api/tss/teams/* and /api/tss/leaderboard/teams to SREBOT/server.js, reading tss_battles.db and tss_teams.db with queries adapted to the team_id / team_name / teams_data schema. Mirrors the existing /api/squadrons/* endpoints. SREBOT/web/server.js gains matching proxy routes so the frontend can reach them via the website host. TSSBOT/BOT/storage.py picks up the columns and table the endpoints need to return meaningful data: teams_data.clanrating, team_members.points, and a new teams_points history table. All idempotent under the existing init_tss_dbs() call. Co-authored-by: Heidi Co-authored-by: Claude Opus 4.7 (1M context) --- server.js | 678 ++++++++++++++++++++++++++++++++++++++++++++++++++ web/server.js | 92 +++++++ 2 files changed, 770 insertions(+) diff --git a/server.js b/server.js index df2778f..f9298df 100644 --- a/server.js +++ b/server.js @@ -22,6 +22,8 @@ const REPLAYS_PATH = path.join(STORAGE_ROOT, 'REPLAYS'); const COMPS_PATH = path.join(STORAGE_ROOT, 'COMPS'); const WL_DB_PATH = path.join(STORAGE_ROOT, 'wl.db'); const POINTS_DB_PATH = path.join(STORAGE_ROOT, 'points.db'); +const TSS_TEAMS_DB_PATH = path.join(STORAGE_ROOT, 'tss_teams.db'); +const TSS_BATTLES_DB_PATH = path.join(STORAGE_ROOT, 'tss_battles.db'); fs.mkdirSync(REPLAYS_PATH, { recursive: true }); function replayDataPath(sessionId) { @@ -123,8 +125,11 @@ const STATS_CACHE_TTL = 30 * 60 * 1000; // Global stats change slowly — cache const inflightRequests = new Map(); // dedup: key -> Promise let squadronLookupCache = null; let squadronLookupCacheTime = 0; +let tssTeamLookupCache = null; +let tssTeamLookupCacheTime = 0; const SQUADRON_CACHE_TTL = 30 * 60 * 1000; let hasSquadronColumn = false; +let hasTssTeamColumn = false; let nickLookupCache = null; let nickLookupCacheTime = 0; const performanceBenchmarkCache = new Map(); @@ -418,6 +423,87 @@ function loadSquadronLookupCached(callback) { }); } +function loadTssTeamLookupCached(callback) { + if (tssTeamLookupCache && Date.now() - tssTeamLookupCacheTime < SQUADRON_CACHE_TTL) { + return callback(tssTeamLookupCache); + } + + if (!fs.existsSync(TSS_TEAMS_DB_PATH)) { + tssTeamLookupCache = {}; + tssTeamLookupCacheTime = Date.now(); + return callback({}); + } + + const teamsDb = new sqlite3.Database(TSS_TEAMS_DB_PATH, sqlite3.OPEN_READONLY, (err) => { + if (err) { + log.error('Failed to open TSS teams database', err); + tssTeamLookupCache = {}; + tssTeamLookupCacheTime = Date.now(); + return callback({}); + } + + teamsDb.all(`SELECT team_id AS clan_id, long_name, short_name, tag_name, members, clanrating FROM teams_data`, [], (err, rows) => { + teamsDb.close(); + if (err) { + log.error('Error loading TSS team lookup data', err); + tssTeamLookupCache = {}; + tssTeamLookupCacheTime = Date.now(); + return callback(tssTeamLookupCache); + } + + const lookup = {}; + const addAlias = (key, row) => { + if (key === undefined || key === null || key === '') return; + lookup[key] = row; + const lower = String(key).toLowerCase(); + if (!(lower in lookup)) lookup[lower] = row; + }; + (rows || []).forEach(row => { + if (row.long_name) addAlias(row.long_name, row); + if (row.short_name) addAlias(row.short_name, row); + if (row.tag_name) addAlias(row.tag_name, row); + if (row.clan_id !== null && row.clan_id !== undefined) addAlias(String(row.clan_id), row); + }); + + tssTeamLookupCache = lookup; + tssTeamLookupCacheTime = Date.now(); + log.info('TSS team lookup cache updated', { entries: (rows || []).length }); + callback(tssTeamLookupCache); + }); + }); +} + +function resolveTssTeam(input, lookup) { + const raw = input == null ? '' : String(input).trim(); + const hit = raw && lookup ? (lookup[raw] || lookup[raw.toLowerCase()]) : null; + const canonicalName = hit ? (hit.long_name || raw) : raw; + const variants = new Set(); + if (raw) variants.add(raw); + if (canonicalName) variants.add(canonicalName); + if (hit) { + if (hit.long_name) variants.add(hit.long_name); + if (hit.short_name) variants.add(hit.short_name); + if (hit.tag_name) variants.add(hit.tag_name); + Object.keys(lookup || {}).forEach(key => { + if (lookup[key] && lookup[key].long_name === hit.long_name) variants.add(key); + }); + } + return { + row: hit || null, + clanId: hit && hit.clan_id != null ? Number(hit.clan_id) : null, + canonicalName, + tagName: hit ? (hit.tag_name || hit.short_name || canonicalName) : raw, + variants: Array.from(variants).filter(Boolean), + }; +} + +function tssDbUnavailable(res) { + return res.status(404).json({ + error: 'TSS database not found', + required_databases: ['tss_battles.db', 'tss_teams.db'] + }); +} + function loadNickLookupCached(callback) { if (nickLookupCache && Date.now() - nickLookupCacheTime < SQUADRON_CACHE_TTL) { return callback(nickLookupCache); @@ -1785,6 +1871,29 @@ const heavyDb = new sqlite3.Database(DB_PATH, sqlite3.OPEN_READONLY, (err) => { else log.info('Heavy-reader connection open'); }); +let tssDb = null; +let tssHeavyDb = null; +if (fs.existsSync(TSS_BATTLES_DB_PATH)) { + tssHeavyDb = new sqlite3.Database(TSS_BATTLES_DB_PATH, sqlite3.OPEN_READONLY, (err) => { + if (err) log.warn('Failed to open TSS heavy reader connection:', err.message); + else log.info('TSS heavy-reader connection open'); + }); + tssDb = new sqlite3.Database(TSS_BATTLES_DB_PATH, sqlite3.OPEN_READONLY, (err) => { + if (err) { + log.error('Failed to open TSS battles database', err, { dbPath: TSS_BATTLES_DB_PATH }); + tssDb = null; + return; + } + log.info('Connected to TSS battles database in read-only mode', { dbPath: TSS_BATTLES_DB_PATH }); + tssDb.all("PRAGMA table_info(player_games_hist)", (schemaErr, cols) => { + if (!schemaErr) hasTssTeamColumn = cols.some(c => c.name === 'team_name'); + log.info('TSS schema check complete', { hasTssTeamColumn }); + }); + }); +} else { + log.warn('TSS battles database not found; /api/tss endpoints will return 404', { dbPath: TSS_BATTLES_DB_PATH }); +} + const db = new sqlite3.Database(DB_PATH, sqlite3.OPEN_READONLY, (err) => { if (err) { log.error('Failed to open database', err, { dbPath: DB_PATH }); @@ -2372,6 +2481,7 @@ app.get('/health', (req, res) => { status: 'OK', timestamp: new Date().toISOString(), database: 'Connected', + tss_database: tssDb ? 'Connected' : 'Unavailable', ready: serverReady, uptime_ms: Date.now() - startupReadyAt, }); @@ -3063,6 +3173,15 @@ const API_INFO = { "GET /api/squadrons/:squadronname": "Get squadron roster stats and summary", "GET /api/squadrons/:squadronname/history": "Get squadron battle and rating history", "GET /api/squadrons/:squadronname/games": "Get squadron match list", + "GET /api/tss/teams/resolve": "Resolve TSS team short/tag/long name to canonical metadata", + "GET /api/tss/leaderboard/teams": "Get TSS team leaderboards", + "GET /api/tss/leaderboard/squadrons": "Alias for TSS team leaderboards", + "GET /api/tss/teams/:teamname": "Get TSS team roster stats and summary", + "GET /api/tss/teams/:teamname/history": "Get TSS team battle and rating history", + "GET /api/tss/teams/:teamname/games": "Get TSS team match list", + "GET /api/tss/squadrons/:squadronname": "Alias for TSS team roster stats and summary", + "GET /api/debug/tss-teams-db-schema": "Inspect TSS teams database schema", + "GET /api/debug/tss-battles-db-schema": "Inspect TSS battles database schema", "GET /health": "Health check endpoint", "GET /api/info": "API information" }, @@ -3205,6 +3324,65 @@ const API_INFO = { ] } ] + }, + { + file: "tss_battles.db", + purpose: "TSS battle history store. Same schema as sq_battles.db and served by /api/tss endpoints.", + tables: [ + { + name: "player_games_hist", + description: "One row per TSS player per battle / vehicle.", + used_by: [ + "GET /api/tss/leaderboard/teams", + "GET /api/tss/leaderboard/squadrons", + "GET /api/tss/teams/:teamname", + "GET /api/tss/teams/:teamname/history", + "GET /api/tss/teams/:teamname/games", + "GET /api/tss/squadrons/:squadronname", + "GET /api/debug/tss-battles-db-schema" + ] + }, + { + name: "match_summary", + description: "One row per TSS match.", + used_by: [ + "GET /api/tss/teams/:teamname/games", + "GET /api/debug/tss-battles-db-schema" + ] + } + ] + }, + { + file: "tss_teams.db", + purpose: "TSS team roster cache, team metadata, and historical point snapshots. Served by /api/tss endpoints.", + tables: [ + { + name: "teams_data", + description: "Canonical TSS team directory and latest leaderboard snapshot.", + used_by: [ + "GET /api/tss/teams/resolve", + "GET /api/tss/leaderboard/teams", + "GET /api/tss/teams/:teamname", + "GET /api/tss/teams/:teamname/history", + "GET /api/debug/tss-teams-db-schema" + ] + }, + { + name: "team_members", + description: "Current per-team member roster with cached nick and points values.", + used_by: [ + "GET /api/tss/teams/:teamname" + ] + }, + { + name: "teams_points", + description: "Historical TSS team point snapshots keyed by unix time.", + used_by: [ + "GET /api/tss/teams/:teamname/history", + "GET /api/debug/tss-teams-db-schema" + ] + } + ] } ] }; @@ -4834,6 +5012,500 @@ app.get('/api/squadrons/:squadronname/games', (req, res) => { }); }); +function sendSqliteSchema(res, dbPath, label) { + if (!fs.existsSync(dbPath)) { + return res.status(404).json({ + error: `${label} database not found`, + path: dbPath + }); + } + + const schemaDb = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY, (err) => { + if (err) { + return res.status(500).json({ + error: `Failed to open ${label} database`, + details: err.message + }); + } + + schemaDb.all(`SELECT name FROM sqlite_master WHERE type='table'`, (err, tables) => { + if (err) { + schemaDb.close(); + return res.status(500).json({ error: 'Failed to get table list', details: err.message }); + } + + const tableSchemas = {}; + let completedTables = 0; + if (!tables.length) { + schemaDb.close(); + return res.json({ database_path: dbPath, tables: {}, message: `No tables found in ${label} database` }); + } + + tables.forEach(table => { + const tableName = table.name; + schemaDb.all(`PRAGMA table_info(${tableName})`, (schemaErr, schema) => { + if (schemaErr) { + tableSchemas[tableName] = { error: schemaErr.message }; + completedTables++; + if (completedTables === tables.length) { + schemaDb.close(); + res.json({ database_path: dbPath, tables: tableSchemas, timestamp: new Date().toISOString() }); + } + return; + } + + schemaDb.all(`SELECT * FROM ${tableName} LIMIT 3`, (sampleErr, samples) => { + tableSchemas[tableName] = { + schema, + sample_records: sampleErr ? [] : (samples || []), + record_count: sampleErr ? 0 : (samples || []).length + }; + completedTables++; + if (completedTables === tables.length) { + schemaDb.close(); + res.json({ database_path: dbPath, tables: tableSchemas, timestamp: new Date().toISOString() }); + } + }); + }); + }); + }); + }); +} + +app.get('/api/debug/tss-teams-db-schema', requireAdminBearer, (req, res) => { + sendSqliteSchema(res, TSS_TEAMS_DB_PATH, 'TSS teams'); +}); + +app.get('/api/debug/tss-battles-db-schema', requireAdminBearer, (req, res) => { + sendSqliteSchema(res, TSS_BATTLES_DB_PATH, 'TSS battles'); +}); + +function tssAll(database, query, params = []) { + return new Promise((resolve, reject) => { + if (!database) return reject(new Error('TSS database unavailable')); + database.all(query, params, (err, rows) => err ? reject(err) : resolve(rows || [])); + }); +} + +function tssGet(database, query, params = []) { + return new Promise((resolve, reject) => { + if (!database) return reject(new Error('TSS database unavailable')); + database.get(query, params, (err, row) => err ? reject(err) : resolve(row || null)); + }); +} + +function tssDateParams(dateFilters) { + return [ + dateFilters.startTimestamp, + dateFilters.startTimestamp, + dateFilters.endTimestamp, + dateFilters.endTimestamp, + ]; +} + +function tssAttrClause(alias, team) { + const prefix = alias ? `${alias}.` : ''; + if (team.clanId != null) return { clause: `${prefix}team_id = ?`, params: [team.clanId] }; + const variants = team.variants.length ? team.variants : [team.canonicalName]; + return { + clause: `${prefix}team_name IN (${variants.map(() => '?').join(',')})`, + params: variants, + }; +} + +function registerTssTeamRoutes(teamBasePath) { + app.get(`${teamBasePath}/resolve`, (req, res) => { + const name = req.query.name || req.query.q || req.query.team || req.query.squadron || ''; + if (!name) return res.status(400).json({ error: 'name query parameter is required' }); + loadTssTeamLookupCached((lookup) => { + const team = resolveTssTeam(name, lookup); + res.json({ + query: String(name), + resolved: !!team.row, + clan_id: team.clanId, + long_name: team.canonicalName, + short_name: team.row ? team.row.short_name : null, + tag_name: team.tagName, + members: team.row ? team.row.members : null, + clanrating: team.row ? team.row.clanrating : null, + source: 'tss_teams.db' + }); + }); + }); + + app.get(`${teamBasePath}/:teamname`, (req, res) => { + if (!tssDb) return tssDbUnavailable(res); + if (!hasTssTeamColumn) { + return res.json({ + tag_name: req.params.teamname, + long_name: req.params.teamname, + team_summary: { player_count: 0, total_kills: 0, total_battles: 0, wins: 0 }, + players: [], + migration_needed: true + }); + } + + const { teamname } = req.params; + const dateFilters = parseDateFilters(req); + const { start_date, end_date, season, week } = req.query; + const cacheKey = `tss_team_detail_${teamBasePath}_${teamname}_${start_date || ''}_${end_date || ''}_${season || ''}_${week || ''}`; + const cached = getCachedResponse(cacheKey); + if (cached) return res.json(cached); + + loadTssTeamLookupCached((lookup) => { + const team = resolveTssTeam(teamname, lookup); + const attr = tssAttrClause('p', team); + const summaryAttr = tssAttrClause('', team); + + const membersPromise = new Promise((resolve) => { + if (!team.clanId || !fs.existsSync(TSS_TEAMS_DB_PATH)) return resolve([]); + const teamsDb = new sqlite3.Database(TSS_TEAMS_DB_PATH, sqlite3.OPEN_READONLY, (err) => { + if (err) return resolve([]); + teamsDb.all(`SELECT uid, nick, points FROM team_members WHERE team_id = ?`, [team.clanId], (err, rows) => { + teamsDb.close(); + resolve(err ? [] : (rows || [])); + }); + }); + }); + + const playerStatsQuery = ` + SELECT + p.UID as uid, + MAX(p.nick) as nick, + SUM(p.ground_kills) as total_ground_kills, + SUM(p.air_kills) as total_air_kills, + SUM(p.ground_kills + p.air_kills) as total_kills, + SUM(p.assists) as total_assists, + SUM(p.captures) as total_captures, + SUM(p.deaths) as total_deaths, + COUNT(*) as total_battles, + COUNT(DISTINCT p.session_id) as sessions, + SUM(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins + FROM player_games_hist p + WHERE ${attr.clause} + AND p.UID IS NOT NULL + AND (? IS NULL OR p.endtime_unix >= ?) + AND (? IS NULL OR p.endtime_unix <= ?) + GROUP BY p.UID + `; + const summaryQuery = ` + SELECT + COUNT(DISTINCT session_id) as total_battles, + COUNT(DISTINCT CASE WHEN UPPER(victor_bool) = 'WIN' THEN session_id END) as wins, + SUM(ground_kills) as total_ground_kills, + SUM(air_kills) as total_air_kills, + SUM(ground_kills + air_kills) as total_kills, + SUM(assists) as total_assists, + SUM(captures) as total_captures, + SUM(deaths) as total_deaths + FROM player_games_hist + WHERE ${summaryAttr.clause} + AND UID IS NOT NULL + AND (? IS NULL OR endtime_unix >= ?) + AND (? IS NULL OR endtime_unix <= ?) + `; + + Promise.all([ + membersPromise, + tssAll(tssDb, playerStatsQuery, [...attr.params, ...tssDateParams(dateFilters)]), + tssGet(tssDb, summaryQuery, [...summaryAttr.params, ...tssDateParams(dateFilters)]) + ]).then(([memberRows, statsRows, summaryRow]) => { + const memberByUid = new Map((memberRows || []).map(r => [String(r.uid), r])); + const players = (statsRows || []).map(row => { + const uid = String(row.uid); + const member = memberByUid.get(uid) || {}; + const kills = row.total_kills || 0; + const deaths = row.total_deaths || 0; + const battles = row.total_battles || 0; + const wins = row.wins || 0; + return { + uid, + nick: member.nick || row.nick || '', + points: member.points || 0, + total_kills: kills, + ground_kills: row.total_ground_kills || 0, + air_kills: row.total_air_kills || 0, + total_battles: battles, + wins, + win_rate: battles > 0 ? parseFloat(((wins / battles) * 100).toFixed(1)) : 0, + kdr: deaths > 0 ? parseFloat((kills / deaths).toFixed(1)) : kills, + deaths, + assists: row.total_assists || 0, + captures: row.total_captures || 0 + }; + }).sort((a, b) => b.total_kills - a.total_kills); + + const summary = summaryRow || {}; + const totalKills = summary.total_kills || 0; + const deaths = summary.total_deaths || 0; + const totalBattles = summary.total_battles || 0; + const wins = summary.wins || 0; + const response = { + data_set: 'tss', + date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), + clan_id: team.clanId, + tag_name: team.tagName, + short_name: team.row ? (team.row.short_name || team.tagName) : team.tagName, + long_name: team.canonicalName, + team_summary: { + player_count: team.row ? (team.row.members || players.length) : players.length, + total_kills: totalKills, + ground_kills: summary.total_ground_kills || 0, + air_kills: summary.total_air_kills || 0, + total_battles: totalBattles, + wins, + win_rate: totalBattles > 0 ? parseFloat(((wins / totalBattles) * 100).toFixed(1)) : 0, + kdr: deaths > 0 ? parseFloat((totalKills / deaths).toFixed(1)) : totalKills, + deaths, + assists: summary.total_assists || 0, + captures: summary.total_captures || 0, + points: { + total_points: team.row ? (team.row.clanrating || 0) : 0, + has_points_data: !!(team.row && team.row.clanrating) + } + }, + squadron_summary: null, + players + }; + response.squadron_summary = response.team_summary; + setCachedResponse(cacheKey, response); + res.json(response); + }).catch(err => { + log.error('Database error in TSS team details query', err, { teamName: teamname }); + res.status(500).json({ error: 'Database error', errorCode: 'DB_TSS_TEAM_QUERY_FAILED' }); + }); + }); + }); + + app.get(`${teamBasePath}/:teamname/history`, (req, res) => { + if (!tssDb) return tssDbUnavailable(res); + const { teamname } = req.params; + const cacheKey = `tss_team_history_${teamBasePath}_${teamname}`; + const cached = getCachedResponse(cacheKey); + if (cached) return res.json(cached); + + loadTssTeamLookupCached((lookup) => { + const team = resolveTssTeam(teamname, lookup); + const attr = tssAttrClause('', team); + const historyQuery = ` + SELECT + date(endtime_unix, 'unixepoch') as period, + COUNT(DISTINCT session_id) as battles, + ROUND(SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1.0 ELSE 0 END) * 100.0 / NULLIF(COUNT(*), 0), 1) as win_rate, + ROUND(CAST(SUM(ground_kills + air_kills) AS REAL) / MAX(SUM(deaths), 1), 2) as kdr + FROM player_games_hist + WHERE ${attr.clause} + AND UID IS NOT NULL + AND endtime_unix IS NOT NULL + GROUP BY period + ORDER BY period ASC + `; + + const ratingPromise = new Promise((resolve) => { + if (!fs.existsSync(TSS_TEAMS_DB_PATH)) return resolve([]); + const teamsDb = new sqlite3.Database(TSS_TEAMS_DB_PATH, sqlite3.OPEN_READONLY, (err) => { + if (err) return resolve([]); + const ratingQuery = team.clanId + ? `SELECT unix_time, total_score FROM teams_points WHERE team_id = ? ORDER BY unix_time ASC` + : `SELECT unix_time, total_score FROM teams_points WHERE long_name = ? ORDER BY unix_time ASC`; + teamsDb.all(ratingQuery, [team.clanId || team.canonicalName], (err, rows) => { + teamsDb.close(); + resolve(err ? [] : (rows || [])); + }); + }); + }); + + Promise.all([ + tssAll(tssDb, historyQuery, attr.params), + ratingPromise + ]).then(([historyRows, ratingRows]) => { + const response = { + data_set: 'tss', + history: historyRows, + rating_hourly: ratingRows.map(r => ({ t: r.unix_time, rating: r.total_score })) + }; + setCachedResponse(cacheKey, response); + res.json(response); + }).catch(err => { + log.error('Database error in TSS team history query', err, { teamName: teamname }); + res.status(500).json({ error: 'Database error', errorCode: 'DB_TSS_TEAM_HISTORY_FAILED' }); + }); + }); + }); + + app.get(`${teamBasePath}/:teamname/games`, (req, res) => { + if (!tssDb) return tssDbUnavailable(res); + const { teamname } = req.params; + const dateFilters = parseDateFilters(req); + const cacheKey = `tss_team_games_${teamBasePath}_${teamname}_${dateFilters.startTimestamp || 'null'}_${dateFilters.endTimestamp || 'null'}`; + const cached = getCachedResponse(cacheKey); + if (cached) return res.json(cached); + + loadTssTeamLookupCached((lookup) => { + const team = resolveTssTeam(teamname, lookup); + const attr = tssAttrClause('p', team); + const gamesQuery = ` + SELECT + p.session_id, + MAX(p.endtime_unix) as endtime_unix, + GROUP_CONCAT(DISTINCT p.nick) as players, + COUNT(DISTINCT p.UID) as player_count, + SUM(p.ground_kills) as ground_kills, + SUM(p.air_kills) as air_kills, + SUM(p.assists) as assists, + SUM(p.captures) as captures, + SUM(p.deaths) as deaths, + CASE WHEN SUM(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END) * 2 > COUNT(*) THEN 'Win' ELSE 'Loss' END as result, + ms.map_name + FROM player_games_hist p + LEFT JOIN match_summary ms ON ms.session_id = p.session_id + WHERE ${attr.clause} + AND p.UID IS NOT NULL + AND p.session_id IS NOT NULL + AND (? IS NULL OR p.endtime_unix >= ?) + AND (? IS NULL OR p.endtime_unix <= ?) + GROUP BY p.session_id + ORDER BY endtime_unix DESC + LIMIT 2000 + `; + + tssAll(tssDb, gamesQuery, [...attr.params, ...tssDateParams(dateFilters)]).then(rows => { + const response = { + data_set: 'tss', + tag_name: team.tagName, + long_name: team.canonicalName, + games: rows.map(row => ({ + session_id: row.session_id, + timestamp: row.endtime_unix || 0, + map_name: row.map_name || null, + players: row.players || '', + player_count: row.player_count || 0, + stats: { + ground_kills: row.ground_kills || 0, + air_kills: row.air_kills || 0, + assists: row.assists || 0, + captures: row.captures || 0, + deaths: row.deaths || 0 + }, + result: row.result || 'Unknown' + })), + total_games_returned: rows.length + }; + setCachedResponse(cacheKey, response); + res.json(response); + }).catch(err => { + log.error('Database error in TSS team games query', err, { teamName: teamname }); + res.status(500).json({ error: 'Database error', errorCode: 'DB_TSS_TEAM_GAMES_FAILED' }); + }); + }); + }); +} + +function handleTssTeamLeaderboard(req, res) { + if (!tssDb || !tssHeavyDb) return tssDbUnavailable(res); + const dateFilters = parseDateFilters(req); + const { start_date, end_date, season, week, limit } = req.query; + const maxRows = Math.max(1, Math.min(1000, parseInt(limit) || 1000)); + const cacheKey = `tss_leaderboard_teams_${start_date || 'all'}_${end_date || 'all'}_${season || 'all'}_${week || 'all'}_${maxRows}`; + const cached = getCachedResponse(cacheKey); + if (cached) return res.json(cached); + + const query = ` + SELECT + team_id AS clan_id, + team_name AS squadron_name, + COUNT(DISTINCT UID) as player_count, + COUNT(*) as total_battles, + COUNT(DISTINCT session_id) as sessions, + COUNT(DISTINCT CASE WHEN UPPER(victor_bool) = 'WIN' THEN session_id END) as wins, + SUM(ground_kills + air_kills) as total_kills, + SUM(ground_kills) as ground_kills, + SUM(air_kills) as air_kills, + SUM(assists) as assists, + SUM(captures) as captures, + SUM(deaths) as deaths + FROM player_games_hist + WHERE team_name IS NOT NULL + AND team_name != '' + AND (? IS NULL OR endtime_unix >= ?) + AND (? IS NULL OR endtime_unix <= ?) + GROUP BY team_id, team_name + `; + + Promise.all([ + tssAll(tssHeavyDb, query, tssDateParams(dateFilters)), + new Promise(resolve => loadTssTeamLookupCached(resolve)) + ]).then(([rows, lookup]) => { + const consolidated = {}; + for (const row of rows) { + const lookupRow = row.clan_id != null ? (lookup[String(row.clan_id)] || null) : null; + const key = row.clan_id != null ? `id:${row.clan_id}` : `name:${row.squadron_name}`; + if (!consolidated[key]) { + consolidated[key] = { + clan_id: row.clan_id || (lookupRow ? lookupRow.clan_id : null), + tag_name: lookupRow ? lookupRow.tag_name : row.squadron_name, + short_name: lookupRow ? lookupRow.short_name : row.squadron_name, + long_name: lookupRow ? lookupRow.long_name : row.squadron_name, + player_count: lookupRow ? (lookupRow.members || 0) : 0, + total_battles: 0, + wins: 0, + total_kills: 0, + ground_kills: 0, + air_kills: 0, + deaths: 0, + assists: 0, + captures: 0, + points: { + total_points: lookupRow ? (lookupRow.clanrating || 0) : 0, + has_points_data: !!(lookupRow && lookupRow.clanrating) + } + }; + } + const item = consolidated[key]; + item.player_count = Math.max(item.player_count || 0, row.player_count || 0); + item.total_battles += row.sessions || row.total_battles || 0; + item.wins += row.wins || 0; + item.total_kills += row.total_kills || 0; + item.ground_kills += row.ground_kills || 0; + item.air_kills += row.air_kills || 0; + item.deaths += row.deaths || 0; + item.assists += row.assists || 0; + item.captures += row.captures || 0; + } + + const teams = Object.values(consolidated).map(team => ({ + ...team, + win_rate: team.total_battles > 0 ? parseFloat(((team.wins / team.total_battles) * 100).toFixed(1)) : 0, + kdr: team.deaths > 0 ? parseFloat((team.total_kills / team.deaths).toFixed(1)) : team.total_kills + })).sort((a, b) => { + if (a.points.has_points_data && b.points.has_points_data) return b.points.total_points - a.points.total_points; + if (a.points.has_points_data) return -1; + if (b.points.has_points_data) return 1; + return b.total_kills - a.total_kills; + }); + + const response = { + data_set: 'tss', + date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), + total_teams: teams.length, + total_squadrons: teams.length, + teams: teams.slice(0, maxRows), + squadrons: teams.slice(0, maxRows) + }; + setCachedResponse(cacheKey, response); + res.json(response); + }).catch(err => { + log.error('Database error in TSS team leaderboard', err); + res.status(500).json({ error: 'Database error', errorCode: 'DB_TSS_TEAM_LEADERBOARD_FAILED' }); + }); +} + +app.get('/api/tss/leaderboard/teams', handleTssTeamLeaderboard); +app.get('/api/tss/leaderboard/squadrons', handleTssTeamLeaderboard); + +registerTssTeamRoutes('/api/tss/teams'); +registerTssTeamRoutes('/api/tss/squadrons'); + app.get('/api/leaderboard/stats', (req, res) => { log.info('Leaderboard stats request received'); const dateFilters = parseDateFilters(req); @@ -5927,6 +6599,10 @@ app.use((req, res) => { 'GET /api/leaderboard/vehicles', 'GET /api/leaderboard/stats', 'GET /api/squadrons/:squadronname', + 'GET /api/tss/leaderboard/teams', + 'GET /api/tss/teams/:teamname', + 'GET /api/tss/teams/:teamname/history', + 'GET /api/tss/teams/:teamname/games', 'GET /api/analytics/maps/:squadron', 'GET /api/analytics/player/squadmates/:uid', 'GET /api/analytics/time/:squadron', @@ -5988,6 +6664,8 @@ process.on('unhandledRejection', (reason) => { process.on('SIGINT', () => { console.log('\nShutting down server...'); + if (tssHeavyDb) tssHeavyDb.close(() => {}); + if (tssDb) tssDb.close(() => {}); db.close((err) => { if (err) { console.error('Error closing database:', err.message); diff --git a/web/server.js b/web/server.js index d1a0165..23ba5d5 100644 --- a/web/server.js +++ b/web/server.js @@ -2181,6 +2181,98 @@ app.get('/api/squadrons/:name/games', cors(apiCorsOptions), async (req, res) => } }); +// ── TSS proxy routes ─────────────────────────────────────────────────────── +// Mirrors the /api/squadrons/* proxy block above but targets the upstream +// /api/tss/* endpoints (defined in SREBOT/server.js) which read tss_battles.db +// and tss_teams.db. Both /api/tss/teams/* and /api/tss/squadrons/* are +// supported upstream — we proxy the canonical /teams/* path here. +const TSS_BACKEND = () => process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'; + +function _forwardDateParams(req) { + const qp = new URLSearchParams(); + if (req.query.start_date) qp.append('start_date', req.query.start_date); + if (req.query.end_date) qp.append('end_date', req.query.end_date); + if (req.query.season) qp.append('season', req.query.season); + if (req.query.week) qp.append('week', req.query.week); + return qp; +} + +app.get('/api/tss/teams/resolve', cors(apiCorsOptions), async (req, res) => { + try { + const name = req.query.name || req.query.q || req.query.team || ''; + const apiUrl = `${TSS_BACKEND()}/api/tss/teams/resolve?name=${encodeURIComponent(name)}`; + const response = await fetch(apiUrl); + if (!response.ok) return res.status(response.status).json({ error: 'Failed to resolve TSS team' }); + res.json(await response.json()); + } catch (err) { + log.error('Error resolving TSS team', err); + res.status(500).json({ error: 'Failed to resolve TSS team' }); + } +}); + +app.get('/api/tss/leaderboard/teams', cors(apiCorsOptions), async (req, res) => { + try { + const qp = _forwardDateParams(req); + if (req.query.limit) qp.append('limit', req.query.limit); + const qs = qp.toString(); + const apiUrl = `${TSS_BACKEND()}/api/tss/leaderboard/teams${qs ? '?' + qs : ''}`; + const response = await fetch(apiUrl); + if (!response.ok) return res.status(response.status).json({ error: 'Failed to fetch TSS leaderboard', teams: [], total_teams: 0 }); + res.json(await response.json()); + } catch (err) { + log.error('Error in TSS leaderboard proxy', err); + res.status(500).json({ error: 'Failed to fetch TSS leaderboard', teams: [], total_teams: 0 }); + } +}); + +app.get('/api/tss/teams/:name', cors(apiCorsOptions), async (req, res) => { + try { + const qs = _forwardDateParams(req).toString(); + const apiUrl = `${TSS_BACKEND()}/api/tss/teams/${encodeURIComponent(req.params.name)}${qs ? '?' + qs : ''}`; + const response = await fetch(apiUrl); + if (!response.ok) { + if (response.status === 404) { + return res.status(404).json({ error: 'TSS team not found', players: [] }); + } + return res.status(response.status).json({ error: 'Failed to fetch TSS team details' }); + } + res.json(await response.json()); + } catch (err) { + log.error('Error fetching TSS team details', err); + res.status(500).json({ error: 'Failed to fetch TSS team details' }); + } +}); + +app.get('/api/tss/teams/:name/history', cors(apiCorsOptions), async (req, res) => { + try { + const apiUrl = `${TSS_BACKEND()}/api/tss/teams/${encodeURIComponent(req.params.name)}/history`; + const response = await fetch(apiUrl); + if (!response.ok) return res.status(response.status).json({ error: 'Failed to fetch TSS team history' }); + res.json(await response.json()); + } catch (err) { + log.error('Error fetching TSS team history', err); + res.status(500).json({ error: 'Failed to fetch TSS team history' }); + } +}); + +app.get('/api/tss/teams/:name/games', cors(apiCorsOptions), async (req, res) => { + try { + const qs = _forwardDateParams(req).toString(); + const apiUrl = `${TSS_BACKEND()}/api/tss/teams/${encodeURIComponent(req.params.name)}/games${qs ? '?' + qs : ''}`; + const response = await fetch(apiUrl); + if (!response.ok) { + if (response.status === 404) { + return res.status(404).json({ games: [], total_games_returned: 0, error: 'TSS team not found' }); + } + return res.status(response.status).json({ error: 'Failed to fetch TSS team games' }); + } + res.json(await response.json()); + } catch (err) { + log.error('Error fetching TSS team games', err); + res.status(500).json({ error: 'Failed to fetch TSS team games', games: [], total_games_returned: 0 }); + } +}); + // API Routes with better error handling let statsCache = null; let statsCacheTime = 0;