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) { super(message); this.status = status; } } 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 */ 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 [rows] = await pool.query("SELECT * FROM current_games WHERE id = ?", [gameId]); if (!rows.length) throw new ApiError(404, "Game not found"); res.json(rows[0]); })); /** * @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, current_playing_user) VALUES (?, ?, ?)", [true, is_local, user] ); 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"); } 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 [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"); } 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]]; } await conn.query( "UPDATE current_games SET is_open = FALSE, turn_order = ? WHERE id = ?", [JSON.stringify(turnOrder), gameId] ); await conn.commit(); res.json({ message: "Game locked", turnOrder }); } catch (err) { await conn.rollback(); throw new ApiError(500, "Failed to lock game"); } 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"); } 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;