Files
Hackathon_2025/backend/src/api.js
2025-09-14 03:56:04 +02:00

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;