604 lines
16 KiB
JavaScript
604 lines
16 KiB
JavaScript
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; |