uploaded
This commit is contained in:
7
server/routes/admin.js
Normal file
7
server/routes/admin.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({ ok: true, admin: true, message: "Admin route placeholder active" });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
604
server/routes/players.js
Normal file
604
server/routes/players.js
Normal file
@@ -0,0 +1,604 @@
|
||||
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;
|
||||
176
server/routes/rankings.js
Normal file
176
server/routes/rankings.js
Normal file
@@ -0,0 +1,176 @@
|
||||
const express = require("express");
|
||||
const { pool } = require("../db");
|
||||
const { GAMEMODES, avatarUrl, normalizeGamemode } = require("../config");
|
||||
const { ValidationError, validateRequest } = require("../middleware/validation");
|
||||
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 GM_POINTS_SQL = `
|
||||
CASE gm.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 schema = {
|
||||
params: {
|
||||
fields: {
|
||||
gamemode: {
|
||||
type: "string",
|
||||
required: true,
|
||||
minLength: 3,
|
||||
maxLength: 32,
|
||||
toLowerCase: true,
|
||||
},
|
||||
},
|
||||
allowUnknown: false,
|
||||
},
|
||||
query: {
|
||||
fields: {
|
||||
page: { type: "int", min: 1, max: 50000 },
|
||||
perPage: { type: "int", min: 1, max: 500 },
|
||||
},
|
||||
allowUnknown: false,
|
||||
},
|
||||
};
|
||||
|
||||
function validateOrReply(req, res) {
|
||||
try {
|
||||
return validateRequest(req, schema);
|
||||
} 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 buildDefaultTiers() {
|
||||
return Object.fromEntries(GAMEMODES.map((mode) => [mode, null]));
|
||||
}
|
||||
|
||||
router.get("/rankings/:gamemode", async (req, res) => {
|
||||
const validated = validateOrReply(req, res);
|
||||
if (!validated) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const gamemode = normalizeGamemode(validated.params.gamemode);
|
||||
const page = validated.query.page || 1;
|
||||
const perPage = validated.query.perPage || PER_PAGE;
|
||||
|
||||
if (!GAMEMODES.includes(gamemode)) {
|
||||
return sendError(res, 400, "VALIDATION_ERROR", "Invalid gamemode");
|
||||
}
|
||||
|
||||
const [countRows] = await pool.query(
|
||||
"SELECT COUNT(*) AS total FROM player_ranks WHERE gamemode = ?",
|
||||
[gamemode]
|
||||
);
|
||||
|
||||
const total = Number(countRows[0]?.total || 0);
|
||||
const totalPages = Math.max(Math.ceil(total / perPage), 1);
|
||||
const start = (page - 1) * perPage;
|
||||
|
||||
if (total === 0) {
|
||||
return res.json({
|
||||
gamemode,
|
||||
data: [],
|
||||
pagination: { page, perPage, total, totalPages },
|
||||
});
|
||||
}
|
||||
|
||||
const [pageRows] = await pool.query(
|
||||
`SELECT
|
||||
p.id,
|
||||
p.username,
|
||||
p.region,
|
||||
gm.tier AS gm_tier,
|
||||
COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points,
|
||||
${GM_POINTS_SQL} AS gamemode_points
|
||||
FROM players p
|
||||
JOIN player_ranks gm ON gm.player_id = p.id AND gm.gamemode = ?
|
||||
LEFT JOIN player_ranks pr ON pr.player_id = p.id
|
||||
GROUP BY p.id, p.username, p.region, gm.tier
|
||||
ORDER BY gamemode_points DESC, total_points DESC, p.username ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[gamemode, perPage, start]
|
||||
);
|
||||
|
||||
const playerIds = pageRows.map((row) => row.id);
|
||||
const [allTierRows] = playerIds.length
|
||||
? await pool.query(
|
||||
"SELECT player_id, gamemode, tier FROM player_ranks WHERE player_id IN (?)",
|
||||
[playerIds]
|
||||
)
|
||||
: [[]];
|
||||
|
||||
const byId = new Map();
|
||||
for (let i = 0; i < pageRows.length; i += 1) {
|
||||
const row = pageRows[i];
|
||||
byId.set(row.id, {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
region: row.region,
|
||||
avatarUrl: avatarUrl(row.username),
|
||||
tiers: buildDefaultTiers(),
|
||||
totalPoints: Number(row.total_points || 0),
|
||||
gamemodePoints: Number(row.gamemode_points || 0),
|
||||
position: start + i + 1,
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of allTierRows) {
|
||||
const target = byId.get(row.player_id);
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
target.tiers[row.gamemode] = row.tier;
|
||||
}
|
||||
|
||||
return res.json({
|
||||
gamemode,
|
||||
data: Array.from(byId.values()),
|
||||
pagination: {
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
totalPages,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("GET /api/rankings/:gamemode failed:", error?.message || error);
|
||||
return sendError(res, 500, "RANKINGS_FETCH_FAILED", "Failed to fetch rankings");
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user