586 lines
16 KiB
JavaScript
586 lines
16 KiB
JavaScript
import { Router } from "express";
|
|
import { pool } from './db.js';
|
|
import Joi from 'joi';
|
|
import j2s from "joi-to-swagger";
|
|
|
|
export class ApiError extends Error {
|
|
constructor(status, message, cause) {
|
|
super(message);
|
|
this.status = status;
|
|
this.cause = cause;
|
|
}
|
|
}
|
|
|
|
const router = Router();
|
|
const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
|
|
|
const userSchema = Joi.object({
|
|
username: Joi.string().min(3).max(32).required()
|
|
});
|
|
|
|
const createGameSchema = Joi.object({
|
|
user: Joi.number().integer().positive().required(),
|
|
is_local: Joi.boolean().default(false)
|
|
});
|
|
|
|
const addPlayerSchema = Joi.object({
|
|
user: Joi.number().integer().positive().required()
|
|
});
|
|
|
|
const lockGameSchema = Joi.object({
|
|
user: Joi.number().integer().positive().required()
|
|
});
|
|
|
|
const turnSchema = Joi.object({
|
|
user: Joi.number().integer().positive().required(),
|
|
round_number: Joi.number().integer().min(1).required(),
|
|
start_points: Joi.number().integer().min(0).required(),
|
|
first_throw: Joi.number().integer().min(0).required(),
|
|
second_throw: Joi.number().integer().min(0).required(),
|
|
third_throw: Joi.number().integer().min(0).required(),
|
|
end_points: Joi.number().integer().min(0).required()
|
|
});
|
|
|
|
const schemasByPath = {
|
|
"/api/users": userSchema,
|
|
"/api/games": createGameSchema,
|
|
"/api/games/:id/players": addPlayerSchema,
|
|
"/api/games/:id/lock": lockGameSchema,
|
|
"/api/games/:id/turns": turnSchema
|
|
};
|
|
|
|
export function generateRequestBodies() {
|
|
const requestBodies = {};
|
|
|
|
for (const path in schemasByPath) {
|
|
const { swagger } = j2s(schemasByPath[path]);
|
|
requestBodies[path] = {
|
|
content: {
|
|
"application/json": {
|
|
schema: swagger
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
return requestBodies;
|
|
}
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/healthcheck:
|
|
* get:
|
|
* summary: Check if the API is running and if the database is reachable
|
|
* responses:
|
|
* 200:
|
|
* description: API and DB are healthy
|
|
* content:
|
|
* application/json:
|
|
* example:
|
|
* status: "ok"
|
|
* db: "ok"
|
|
* 500:
|
|
* description: Database unreachable
|
|
* content:
|
|
* application/json:
|
|
* example:
|
|
* status: "error"
|
|
* db: "down"
|
|
*/
|
|
router.get("/healthcheck", async (req, res) => {
|
|
try {
|
|
await pool.query("SELECT 1"); // basic DB check
|
|
res.json({ status: "ok", db: "ok" });
|
|
} catch {
|
|
res.status(500).json({ status: "error", db: "down" });
|
|
}
|
|
});
|
|
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/users:
|
|
* post:
|
|
* summary: Create a new user or return existing user if username already exists
|
|
* responses:
|
|
* 200:
|
|
* description: User created or existing user returned
|
|
* content:
|
|
* application/json:
|
|
* example:
|
|
* id: 1
|
|
* username: "john_doe"
|
|
* 400:
|
|
* description: Validation error
|
|
*/
|
|
router.post("/users", asyncHandler(async (req, res) => {
|
|
const { error, value } = userSchema.validate(req.body);
|
|
if (error) throw new ApiError(400, error.details[0].message);
|
|
|
|
const { username } = value;
|
|
const [result] = await pool.query(
|
|
`INSERT INTO users (username)
|
|
VALUES (?)
|
|
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`,
|
|
[username]
|
|
);
|
|
|
|
const [userRow] = await pool.query("SELECT * FROM users WHERE id = ?", [result.insertId]);
|
|
res.status(200).json(userRow[0]);
|
|
}));
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/games:
|
|
* get:
|
|
* summary: Get all current games
|
|
* responses:
|
|
* 200:
|
|
* description: List of current games
|
|
* content:
|
|
* application/json:
|
|
* example:
|
|
* - id: 1
|
|
* is_open: true
|
|
* is_local: false
|
|
* current_playing_user: 1
|
|
* turn_order: null
|
|
*/
|
|
router.get("/games", async (req, res) => {
|
|
const [rows] = await pool.query("SELECT * FROM current_games");
|
|
res.json(rows);
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/games/{id}:
|
|
* get:
|
|
* summary: Get a specific game by ID
|
|
* parameters:
|
|
* - name: id
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Game data
|
|
* content:
|
|
* application/json:
|
|
* example:
|
|
* id: 1
|
|
* is_open: true
|
|
* is_local: false
|
|
* current_playing_user: 1
|
|
* turn_order: null
|
|
* creator: 1
|
|
*/
|
|
router.get("/games/:id", asyncHandler(async (req, res) => {
|
|
const gameId = parseInt(req.params.id, 10);
|
|
if (isNaN(gameId)) throw new ApiError(400, "Invalid game ID");
|
|
|
|
const [games] = await pool.query("SELECT * FROM current_games WHERE id = ?", [gameId]);
|
|
if (!games.length) throw new ApiError(404, "Game not found");
|
|
|
|
const [creatorRows] = await pool.query(
|
|
`SELECT u.id
|
|
FROM game_players gp
|
|
JOIN users u ON gp.user = u.id
|
|
WHERE gp.game = ? AND gp.is_creator = TRUE
|
|
LIMIT 1`,
|
|
[gameId]
|
|
);
|
|
|
|
const creator = creatorRows.length ? creatorRows[0].id : null;
|
|
|
|
res.json({ ...games[0], creator});
|
|
}));
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/games:
|
|
* post:
|
|
* summary: Create a new game
|
|
* responses:
|
|
* 201:
|
|
* description: Game created
|
|
* content:
|
|
* application/json:
|
|
* example:
|
|
* game:
|
|
* id: 1
|
|
* is_open: true
|
|
* is_local: false
|
|
* current_playing_user: 1
|
|
* turn_order: null
|
|
* players:
|
|
* - id: 1
|
|
* username: "john_doe"
|
|
*/
|
|
router.post("/games", asyncHandler(async (req, res) => {
|
|
const { error, value } = createGameSchema.validate(req.body);
|
|
if (error) throw new ApiError(400, error.details[0].message);
|
|
|
|
const { user, is_local } = value;
|
|
const conn = await pool.getConnection();
|
|
|
|
try {
|
|
await conn.beginTransaction();
|
|
|
|
const [gameResult] = await conn.query(
|
|
"INSERT INTO current_games (is_open, is_local) VALUES (?, ?)",
|
|
[true, is_local]
|
|
);
|
|
|
|
const gameId = gameResult.insertId;
|
|
|
|
await conn.query(
|
|
"INSERT INTO game_players (game, user, is_creator) VALUES (?, ?, ?)",
|
|
[gameId, user, true]
|
|
);
|
|
|
|
const [gameRow] = await conn.query("SELECT * FROM current_games WHERE id = ?", [gameId]);
|
|
const [players] = await conn.query(
|
|
`SELECT u.id, u.username FROM game_players gp
|
|
JOIN users u ON gp.user = u.id
|
|
WHERE gp.game = ?`,
|
|
[gameId]
|
|
);
|
|
|
|
await conn.commit();
|
|
res.status(201).json({ game: gameRow[0], players });
|
|
} catch (err) {
|
|
await conn.rollback();
|
|
throw new ApiError(500, "Failed to create game", err);
|
|
} finally {
|
|
conn.release();
|
|
}
|
|
}));
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/games/{id}/players:
|
|
* post:
|
|
* summary: Add a player to a game
|
|
* parameters:
|
|
* - name: id
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 201:
|
|
* description: Player added
|
|
* content:
|
|
* application/json:
|
|
* example:
|
|
* game: 1
|
|
* players:
|
|
* - id: 1
|
|
* username: "john_doe"
|
|
* - id: 2
|
|
* username: "jane_doe"
|
|
*/
|
|
router.post("/games/:id/players", asyncHandler(async (req, res) => {
|
|
const gameId = parseInt(req.params.id, 10);
|
|
if (isNaN(gameId)) throw new ApiError(400, "Invalid game ID");
|
|
|
|
const { error, value } = addPlayerSchema.validate(req.body);
|
|
if (error) throw new ApiError(400, error.details[0].message);
|
|
|
|
const { user } = value;
|
|
const conn = await pool.getConnection();
|
|
|
|
try {
|
|
await conn.beginTransaction();
|
|
|
|
const [alreadyJoined] = await conn.query(
|
|
"SELECT 1 FROM game_players WHERE game = ? AND user = ?",
|
|
[gameId, user]
|
|
);
|
|
if (alreadyJoined.length) throw new ApiError(400, "User already joined this game");
|
|
|
|
const [gameRows] = await conn.query(
|
|
"SELECT * FROM current_games WHERE id = ? AND is_open = TRUE AND is_local = FALSE",
|
|
[gameId]
|
|
);
|
|
|
|
if (!gameRows.length) throw new ApiError(400, "Game is not open or is local");
|
|
|
|
await conn.query(
|
|
"INSERT IGNORE INTO game_players (game, user, is_creator) VALUES (?, ?, ?)",
|
|
[gameId, user, false]
|
|
);
|
|
|
|
const [players] = await conn.query(
|
|
`SELECT u.id, u.username FROM game_players gp
|
|
JOIN users u ON gp.user = u.id
|
|
WHERE gp.game = ?`,
|
|
[gameId]
|
|
);
|
|
|
|
await conn.commit();
|
|
res.status(201).json({ game: gameId, players });
|
|
} catch (err) {
|
|
await conn.rollback();
|
|
throw new ApiError(500, "Failed to add player", err);
|
|
} finally {
|
|
conn.release();
|
|
}
|
|
}));
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/games/{id}/lock:
|
|
* patch:
|
|
* summary: Lock a game and generate turn order
|
|
* parameters:
|
|
* - name: id
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Game locked with turn order
|
|
* content:
|
|
* application/json:
|
|
* example:
|
|
* message: "Game locked"
|
|
* turnOrder: [1,2,3]
|
|
* 400:
|
|
* description: Invalid game ID / Validation error
|
|
* 403:
|
|
* description: Only the creator can lock the game
|
|
* 500:
|
|
* description: Failed to lock game
|
|
*/
|
|
router.patch("/games/:id/lock", asyncHandler(async (req, res) => {
|
|
const gameId = parseInt(req.params.id, 10);
|
|
if (isNaN(gameId)) throw new ApiError(400, "Invalid game ID");
|
|
|
|
const { error, value } = lockGameSchema.validate(req.body);
|
|
if (error) throw new ApiError(400, error.details[0].message);
|
|
|
|
const { user } = value;
|
|
const conn = await pool.getConnection();
|
|
|
|
try {
|
|
await conn.beginTransaction();
|
|
|
|
const [creatorRows] = await conn.query(
|
|
"SELECT * FROM game_players WHERE game = ? AND user = ? AND is_creator = TRUE",
|
|
[gameId, user]
|
|
);
|
|
|
|
if (!creatorRows.length) throw new ApiError(403, "Only the creator can lock the game");
|
|
|
|
const [players] = await conn.query(
|
|
"SELECT user FROM game_players WHERE game = ?",
|
|
[gameId]
|
|
);
|
|
|
|
const turnOrder = players.map(p => p.user);
|
|
for (let i = turnOrder.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[turnOrder[i], turnOrder[j]] = [turnOrder[j], turnOrder[i]];
|
|
}
|
|
|
|
const currentPlayingUser = turnOrder[0];
|
|
await conn.query(
|
|
"UPDATE current_games SET is_open = FALSE, turn_order = ?, current_playing_user = ? WHERE id = ?",
|
|
[JSON.stringify(turnOrder), currentPlayingUser, gameId]
|
|
);
|
|
|
|
await conn.commit();
|
|
res.json({ message: "Game locked", turnOrder });
|
|
} catch (err) {
|
|
await conn.rollback();
|
|
throw new ApiError(500, "Failed to lock game", err);
|
|
} finally {
|
|
conn.release();
|
|
}
|
|
}));
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/games/{id}/players:
|
|
* get:
|
|
* summary: Get all players in a game
|
|
* parameters:
|
|
* - name: id
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: List of players
|
|
* content:
|
|
* application/json:
|
|
* example:
|
|
* - id: 1
|
|
* username: "john_doe"
|
|
* - id: 2
|
|
* username: "jane_doe"
|
|
*/
|
|
router.get("/games/:id/players", asyncHandler(async (req, res) => {
|
|
const gameId = parseInt(req.params.id, 10);
|
|
if (isNaN(gameId)) throw new ApiError(400, "Invalid game ID");
|
|
|
|
const [players] = await pool.query(
|
|
`SELECT u.id, u.username FROM game_players gp
|
|
JOIN users u ON gp.user = u.id
|
|
WHERE gp.game = ?`,
|
|
[gameId]
|
|
);
|
|
res.json(players);
|
|
}));
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/games/{id}/turns:
|
|
* post:
|
|
* summary: Record a new turn for a player
|
|
* parameters:
|
|
* - name: id
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 201:
|
|
* description: Turn recorded
|
|
* content:
|
|
* application/json:
|
|
* example:
|
|
* turnId: 1
|
|
* nextPlayer: 2
|
|
* 400:
|
|
* description: Validation error / Invalid game ID
|
|
* 403:
|
|
* description: Not your turn
|
|
* 500:
|
|
* description: Failed to record turn
|
|
*/
|
|
router.post("/games/:id/turns", asyncHandler(async (req, res) => {
|
|
const gameId = parseInt(req.params.id, 10);
|
|
if (isNaN(gameId)) throw new ApiError(400, "Invalid game ID");
|
|
|
|
const { error, value } = turnSchema.validate(req.body);
|
|
if (error) throw new ApiError(400, error.details[0].message);
|
|
|
|
const { user, round_number, start_points, first_throw, second_throw, third_throw, end_points } = value;
|
|
const conn = await pool.getConnection();
|
|
|
|
try {
|
|
await conn.beginTransaction();
|
|
|
|
const [gameRows] = await conn.query(
|
|
"SELECT current_playing_user, turn_order FROM current_games WHERE id = ? FOR UPDATE",
|
|
[gameId]
|
|
);
|
|
|
|
if (!gameRows.length) throw new ApiError(404, "Game not found");
|
|
|
|
const game = gameRows[0];
|
|
const turnOrder = JSON.parse(game.turn_order || '[]');
|
|
|
|
if (game.current_playing_user !== user) throw new ApiError(403, "Not your turn");
|
|
|
|
const [result] = await conn.query(
|
|
`INSERT INTO turns
|
|
(game, user, round_number, start_points, first_throw, second_throw, third_throw, end_points)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[gameId, user, round_number, start_points, first_throw, second_throw, third_throw, end_points]
|
|
);
|
|
|
|
const currentIndex = turnOrder.indexOf(user);
|
|
const nextIndex = (currentIndex + 1) % turnOrder.length;
|
|
const nextPlayer = turnOrder[nextIndex];
|
|
|
|
await conn.query(
|
|
"UPDATE current_games SET current_playing_user = ? WHERE id = ?",
|
|
[nextPlayer, gameId]
|
|
);
|
|
|
|
await conn.commit();
|
|
res.status(201).json({ turnId: result.insertId, nextPlayer });
|
|
} catch (err) {
|
|
await conn.rollback();
|
|
throw new ApiError(500, "Failed to record turn", err);
|
|
} finally {
|
|
conn.release();
|
|
}
|
|
}));
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/games/{id}/turns:
|
|
* get:
|
|
* summary: Get turns of a game
|
|
* parameters:
|
|
* - name: id
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 201:
|
|
* description: The turn
|
|
* content:
|
|
* application/json:
|
|
* example:
|
|
* turnId: 1
|
|
* nextPlayer: 2
|
|
*/
|
|
router.get("/games/:id/turns", asyncHandler(async (req, res) => {
|
|
const gameId = parseInt(req.params.id, 10);
|
|
if (isNaN(gameId)) throw new ApiError(400, "Invalid game ID");
|
|
|
|
const [rows] = await pool.query(
|
|
"SELECT * FROM turns WHERE game = ? ORDER BY round_number",
|
|
[gameId]
|
|
);
|
|
res.json(rows);
|
|
}));
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/turns/{id}:
|
|
* get:
|
|
* summary: Get a specific turn by ID
|
|
* parameters:
|
|
* - name: id
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Turn data
|
|
* content:
|
|
* application/json:
|
|
* example:
|
|
* id: 1
|
|
* game: 1
|
|
* user: 1
|
|
* round_number: 1
|
|
* start_points: 501
|
|
* first_throw: 60
|
|
* second_throw: 45
|
|
* third_throw: 36
|
|
* end_points: 360
|
|
*/
|
|
router.get("/turns/:id", asyncHandler(async (req, res) => {
|
|
const turnId = parseInt(req.params.id, 10);
|
|
if (isNaN(turnId)) throw new ApiError(400, "Invalid turn ID");
|
|
|
|
const [rows] = await pool.query("SELECT * FROM turns WHERE id = ?", [turnId]);
|
|
if (!rows.length) throw new ApiError(404, "Turn not found");
|
|
|
|
res.json(rows[0]);
|
|
}));
|
|
|
|
export default router;
|