const express = require("express"); const { pool } = require("../db"); const { GAMEMODES, USERNAME_REGEX, REGIONS, avatarUrl, isValidTier, isValidGamemode, isValidRegion, normalizeGamemode, normalizeRegion, normalizeTier, tierToPoints, } = require("../config"); const { ValidationError, validateRequest } = require("../middleware/validation"); const { requireWriteApiKey } = require("../middleware/security"); const { sendError } = require("../utils/http"); const router = express.Router(); const PER_PAGE = 20; const TIER_POINTS_SQL = ` CASE pr.tier WHEN 'HT1' THEN 60 WHEN 'LT1' THEN 45 WHEN 'HT2' THEN 30 WHEN 'LT2' THEN 20 WHEN 'HT3' THEN 10 WHEN 'LT3' THEN 6 WHEN 'HT4' THEN 4 WHEN 'LT4' THEN 3 WHEN 'HT5' THEN 2 WHEN 'LT5' THEN 1 ELSE 0 END`; const schemas = { playersQuery: { query: { fields: { page: { type: "int", min: 1, max: 50000 }, }, allowUnknown: false, }, }, usernameParam: { params: { fields: { username: { type: "string", required: true, minLength: 3, maxLength: 16, pattern: USERNAME_REGEX, trim: true, }, }, allowUnknown: false, }, }, createPlayerBody: { body: { fields: { username: { type: "string", required: true, minLength: 3, maxLength: 16, pattern: USERNAME_REGEX, trim: true, }, region: { type: "string", required: false, minLength: 2, maxLength: 2, toUpperCase: true, }, }, allowUnknown: false, }, }, updateRegionBody: { body: { fields: { region: { type: "string", required: true, minLength: 2, maxLength: 2, toUpperCase: true, }, }, allowUnknown: false, }, }, updateRankBody: { body: { fields: { gamemode: { type: "string", required: true, minLength: 3, maxLength: 32, toLowerCase: true, }, tier: { type: "string", required: true, minLength: 3, maxLength: 3, toUpperCase: true, }, region: { type: "string", required: false, minLength: 2, maxLength: 2, toUpperCase: true, }, }, allowUnknown: false, }, }, removeRankParams: { params: { fields: { username: { type: "string", required: true, minLength: 3, maxLength: 16, pattern: USERNAME_REGEX, trim: true, }, gamemode: { type: "string", required: true, minLength: 3, maxLength: 32, toLowerCase: true, }, }, allowUnknown: false, }, }, searchQuery: { query: { fields: { q: { type: "string", required: false, minLength: 1, maxLength: 16, pattern: /^[A-Za-z0-9_]+$/, trim: true, }, }, allowUnknown: false, }, }, }; function validateOrReply(req, res, spec) { try { return validateRequest(req, spec); } catch (error) { if (error instanceof ValidationError) { sendError(res, 400, "VALIDATION_ERROR", error.message, error.details); return null; } sendError(res, 400, "VALIDATION_ERROR", "Invalid request payload."); return null; } } function emptyTierMap() { return Object.fromEntries(GAMEMODES.map((mode) => [mode, null])); } function hydratePlayersFromRows(playerRows, rankRows, startPosition = 0) { const byId = new Map(); for (let i = 0; i < playerRows.length; i += 1) { const row = playerRows[i]; byId.set(row.id, { id: row.id, username: row.username, region: row.region, avatarUrl: avatarUrl(row.username), createdAt: row.created_at, tiers: emptyTierMap(), totalPoints: Number(row.total_points || 0), position: startPosition + i + 1, }); } for (const row of rankRows) { const target = byId.get(row.player_id); if (!target) { continue; } target.tiers[row.gamemode] = row.tier; } return Array.from(byId.values()); } async function fetchRanksForPlayerIds(playerIds) { if (!playerIds.length) { return []; } const [rankRows] = await pool.query( "SELECT player_id, gamemode, tier FROM player_ranks WHERE player_id IN (?)", [playerIds] ); return rankRows; } router.get("/players", async (req, res) => { const validated = validateOrReply(req, res, schemas.playersQuery); if (!validated) { return; } try { const page = validated.query.page || 1; const [orderedPlayers] = await pool.query( `SELECT p.id, p.username, p.region, p.created_at, COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points FROM players p LEFT JOIN player_ranks pr ON pr.player_id = p.id GROUP BY p.id, p.username, p.region, p.created_at ORDER BY total_points DESC, p.username ASC` ); const total = orderedPlayers.length; const totalPages = Math.max(Math.ceil(total / PER_PAGE), 1); const start = (page - 1) * PER_PAGE; const pageRows = orderedPlayers.slice(start, start + PER_PAGE); const rankRows = await fetchRanksForPlayerIds(pageRows.map((row) => row.id)); const data = hydratePlayersFromRows(pageRows, rankRows, start); return res.json({ data, pagination: { page, perPage: PER_PAGE, total, totalPages, }, }); } catch (error) { console.error("GET /api/players failed:", error?.message || error); return sendError(res, 500, "PLAYERS_FETCH_FAILED", "Failed to fetch players"); } }); router.get("/players/all", async (req, res) => { try { const [orderedPlayers] = await pool.query( `SELECT p.id, p.username, p.region, p.created_at, COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points FROM players p LEFT JOIN player_ranks pr ON pr.player_id = p.id GROUP BY p.id, p.username, p.region, p.created_at ORDER BY total_points DESC, p.username ASC` ); const rankRows = await fetchRanksForPlayerIds(orderedPlayers.map((row) => row.id)); const data = hydratePlayersFromRows(orderedPlayers, rankRows, 0); return res.json({ data }); } catch (error) { console.error("GET /api/players/all failed:", error?.message || error); return sendError(res, 500, "PLAYERS_FETCH_FAILED", "Failed to fetch all players"); } }); router.get("/players/:username", async (req, res) => { const validated = validateOrReply(req, res, schemas.usernameParam); if (!validated) { return; } try { const username = validated.params.username; const [rows] = await pool.query( `SELECT p.id, p.username, p.region, p.created_at, COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points FROM players p LEFT JOIN player_ranks pr ON pr.player_id = p.id WHERE LOWER(p.username) = LOWER(?) GROUP BY p.id, p.username, p.region, p.created_at LIMIT 1`, [username] ); if (rows.length === 0) { return sendError(res, 404, "PLAYER_NOT_FOUND", "Player not found"); } const player = rows[0]; const [rankRows] = await pool.query( "SELECT player_id, gamemode, tier FROM player_ranks WHERE player_id = ?", [player.id] ); const [positionRows] = await pool.query( `SELECT 1 + COUNT(*) AS position FROM ( SELECT p.id, p.username, COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points FROM players p LEFT JOIN player_ranks pr ON pr.player_id = p.id GROUP BY p.id, p.username ) ranked WHERE ranked.total_points > ? OR (ranked.total_points = ? AND ranked.username < ?)`, [player.total_points, player.total_points, player.username] ); const tiers = emptyTierMap(); for (const row of rankRows) { tiers[row.gamemode] = row.tier; } return res.json({ id: player.id, username: player.username, region: player.region, avatarUrl: avatarUrl(player.username), createdAt: player.created_at, tiers, totalPoints: Number(player.total_points || 0), position: Number(positionRows[0]?.position || 1), }); } catch (error) { console.error("GET /api/players/:username failed:", error?.message || error); return sendError(res, 500, "PLAYER_FETCH_FAILED", "Failed to fetch player profile"); } }); router.post("/player", requireWriteApiKey, async (req, res) => { const validated = validateOrReply(req, res, schemas.createPlayerBody); if (!validated) { return; } try { const username = validated.body.username; const region = normalizeRegion(validated.body.region || "NA"); if (!isValidRegion(region)) { return sendError(res, 400, "VALIDATION_ERROR", `Invalid region. Use one of: ${REGIONS.join(", ")}`); } const [insertResult] = await pool.query( "INSERT INTO players (username, region) VALUES (?, ?)", [username, region] ); return res.status(201).json({ message: "Player created", id: insertResult.insertId, username, region, }); } catch (error) { if (error && error.code === "ER_DUP_ENTRY") { return sendError(res, 409, "USERNAME_EXISTS", "Username already exists"); } console.error("POST /api/player failed:", error?.message || error); return sendError(res, 500, "PLAYER_CREATE_FAILED", "Failed to create player"); } }); router.put("/player/:username/region", requireWriteApiKey, async (req, res) => { const paramValidated = validateOrReply(req, res, schemas.usernameParam); if (!paramValidated) { return; } const bodyValidated = validateOrReply(req, res, schemas.updateRegionBody); if (!bodyValidated) { return; } const username = paramValidated.params.username; const region = normalizeRegion(bodyValidated.body.region); if (!isValidRegion(region)) { return sendError(res, 400, "VALIDATION_ERROR", `Invalid region. Use one of: ${REGIONS.join(", ")}`); } try { const [result] = await pool.query( "UPDATE players SET region = ? WHERE LOWER(username) = LOWER(?)", [region, username] ); if (result.affectedRows === 0) { return sendError(res, 404, "PLAYER_NOT_FOUND", "Player not found"); } return res.json({ message: "Region updated", username, region }); } catch (error) { console.error("PUT /api/player/:username/region failed:", error?.message || error); return sendError(res, 500, "REGION_UPDATE_FAILED", "Failed to update region"); } }); router.delete("/player/:username", requireWriteApiKey, async (req, res) => { const validated = validateOrReply(req, res, schemas.usernameParam); if (!validated) { return; } try { const username = validated.params.username; const [result] = await pool.query("DELETE FROM players WHERE LOWER(username) = LOWER(?)", [username]); if (result.affectedRows === 0) { return sendError(res, 404, "PLAYER_NOT_FOUND", "Player not found"); } return res.json({ message: "Player removed", username }); } catch (error) { console.error("DELETE /api/player/:username failed:", error?.message || error); return sendError(res, 500, "PLAYER_DELETE_FAILED", "Failed to remove player"); } }); router.put("/player/:username/rank", requireWriteApiKey, async (req, res) => { const paramValidated = validateOrReply(req, res, schemas.usernameParam); if (!paramValidated) { return; } const bodyValidated = validateOrReply(req, res, schemas.updateRankBody); if (!bodyValidated) { return; } const username = paramValidated.params.username; const gamemode = normalizeGamemode(bodyValidated.body.gamemode); const tier = normalizeTier(bodyValidated.body.tier); const requestedRegion = bodyValidated.body.region; const region = requestedRegion == null ? null : normalizeRegion(requestedRegion); if (!isValidGamemode(gamemode)) { return sendError(res, 400, "VALIDATION_ERROR", "Invalid gamemode"); } if (!isValidTier(tier)) { return sendError(res, 400, "VALIDATION_ERROR", "Invalid tier"); } if (region !== null && !isValidRegion(region)) { return sendError(res, 400, "VALIDATION_ERROR", `Invalid region. Use one of: ${REGIONS.join(", ")}`); } try { const [playerRows] = await pool.query( "SELECT id, username, region FROM players WHERE LOWER(username) = LOWER(?) LIMIT 1", [username] ); if (playerRows.length === 0) { return sendError(res, 404, "PLAYER_NOT_FOUND", "Player not found"); } const player = playerRows[0]; if (region !== null && player.region !== region) { await pool.query("UPDATE players SET region = ? WHERE id = ?", [region, player.id]); player.region = region; } await pool.query( `INSERT INTO player_ranks (player_id, gamemode, tier) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE tier = VALUES(tier), updated_at = CURRENT_TIMESTAMP`, [player.id, gamemode, tier] ); return res.json({ message: "Rank updated", username: player.username, region: player.region, gamemode, tier, }); } catch (error) { console.error("PUT /api/player/:username/rank failed:", error?.message || error); return sendError(res, 500, "RANK_UPDATE_FAILED", "Failed to update rank"); } }); router.delete("/player/:username/rank/:gamemode", requireWriteApiKey, async (req, res) => { const validated = validateOrReply(req, res, schemas.removeRankParams); if (!validated) { return; } const username = validated.params.username; const gamemode = normalizeGamemode(validated.params.gamemode); if (!isValidGamemode(gamemode)) { return sendError(res, 400, "VALIDATION_ERROR", "Invalid gamemode"); } try { const [playerRows] = await pool.query( "SELECT id, username FROM players WHERE LOWER(username) = LOWER(?) LIMIT 1", [username] ); if (playerRows.length === 0) { return sendError(res, 404, "PLAYER_NOT_FOUND", "Player not found"); } const [result] = await pool.query( "DELETE FROM player_ranks WHERE player_id = ? AND gamemode = ?", [playerRows[0].id, gamemode] ); if (result.affectedRows === 0) { return sendError(res, 404, "RANK_NOT_FOUND", "No rank found for this gamemode"); } return res.json({ message: "Rank removed", username: playerRows[0].username, gamemode }); } catch (error) { console.error("DELETE /api/player/:username/rank/:gamemode failed:", error?.message || error); return sendError(res, 500, "RANK_DELETE_FAILED", "Failed to remove rank"); } }); router.get("/search", async (req, res) => { const validated = validateOrReply(req, res, schemas.searchQuery); if (!validated) { return; } try { const q = validated.query.q || ""; if (!q) { return res.json({ data: [] }); } const [matches] = await pool.query( `SELECT p.id, p.username, p.region, p.created_at, COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points FROM players p LEFT JOIN player_ranks pr ON pr.player_id = p.id WHERE p.username LIKE ? GROUP BY p.id, p.username, p.region, p.created_at ORDER BY total_points DESC, p.username ASC LIMIT 10`, [`%${q}%`] ); if (matches.length === 0) { return res.json({ data: [] }); } const rankRows = await fetchRanksForPlayerIds(matches.map((m) => m.id)); const hydrated = hydratePlayersFromRows(matches, rankRows, 0).map((row) => { const next = { ...row }; delete next.position; return next; }); return res.json({ data: hydrated }); } catch (error) { console.error("GET /api/search failed:", error?.message || error); return sendError(res, 500, "SEARCH_FAILED", "Failed to search players"); } }); module.exports = router;