feat(api): add REST endpoints for lobby create and join

- POST /api/v1/lobbies creates a lobby with a Crockford-Base32
  6-char code, retrying on unique violation up to 5 times
- POST /api/v1/lobbies/:code/join validates lobby state then
  calls the model's atomic addPlayer, idempotent for repeat
  joins from the same user
- Routes require authentication via requireAuth
This commit is contained in:
lila 2026-04-16 19:51:38 +02:00
parent 8c241636bf
commit 4d1ebe2450
4 changed files with 123 additions and 0 deletions

View file

@ -0,0 +1,37 @@
import type { Request, Response, NextFunction } from "express";
import { createLobby, joinLobby } from "../services/lobbyService.js";
export const createLobbyHandler = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const userId = req.session!.user.id;
const lobby = await createLobby(userId);
res.json({ success: true, data: lobby });
} catch (error) {
next(error);
}
};
export const joinLobbyHandler = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const userId = req.session!.user.id;
const code = req.params["code"];
if (!code) {
return next(new Error("Missing code param"));
}
if (typeof code !== "string") {
return next(new Error("Missing or invalid code param"));
}
const lobby = await joinLobby(code, userId);
res.json({ success: true, data: lobby });
} catch (error) {
next(error);
}
};

View file

@ -2,8 +2,10 @@ import express from "express";
import { Router } from "express"; import { Router } from "express";
import { healthRouter } from "./healthRouter.js"; import { healthRouter } from "./healthRouter.js";
import { gameRouter } from "./gameRouter.js"; import { gameRouter } from "./gameRouter.js";
import { lobbyRouter } from "./lobbyRouter.js";
export const apiRouter: Router = express.Router(); export const apiRouter: Router = express.Router();
apiRouter.use("/health", healthRouter); apiRouter.use("/health", healthRouter);
apiRouter.use("/game", gameRouter); apiRouter.use("/game", gameRouter);
apiRouter.use("/lobbies", lobbyRouter);

View file

@ -0,0 +1,14 @@
import express from "express";
import type { Router } from "express";
import {
createLobbyHandler,
joinLobbyHandler,
} from "../controllers/lobbyController.js";
import { requireAuth } from "../middleware/authMiddleware.js";
export const lobbyRouter: Router = express.Router();
lobbyRouter.use(requireAuth);
lobbyRouter.post("/", createLobbyHandler);
lobbyRouter.post("/:code/join", joinLobbyHandler);

View file

@ -0,0 +1,70 @@
import { randomInt } from "crypto";
import {
createLobby as createLobbyModel,
getLobbyByCodeWithPlayers,
addPlayer,
} from "@lila/db";
import type { Lobby, LobbyWithPlayers } from "@lila/db";
import { MAX_LOBBY_PLAYERS } from "@lila/shared";
import { NotFoundError, ConflictError, AppError } from "../errors/AppError.js";
const CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford Base32
const CODE_LENGTH = 6;
const MAX_CODE_ATTEMPTS = 5;
const generateLobbyCode = (): string => {
let code = "";
for (let i = 0; i < CODE_LENGTH; i++) {
code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
}
return code;
};
const isUniqueViolation = (err: unknown): boolean => {
return (err as { code?: string })?.code === "23505";
};
export const createLobby = async (hostUserId: string): Promise<Lobby> => {
for (let i = 0; i < MAX_CODE_ATTEMPTS; i++) {
const code = generateLobbyCode();
try {
return await createLobbyModel(code, hostUserId);
} catch (err) {
if (isUniqueViolation(err)) continue;
throw err;
}
}
throw new AppError("Could not generate a unique lobby code", 500);
};
export const joinLobby = async (
code: string,
userId: string,
): Promise<LobbyWithPlayers> => {
const lobby = await getLobbyByCodeWithPlayers(code);
if (!lobby) {
throw new NotFoundError(`Lobby not found: ${code}`);
}
if (lobby.status !== "waiting") {
throw new ConflictError("Game has already started");
}
if (lobby.players.some((p) => p.userId === userId)) {
return lobby; // idempotent: already in lobby
}
if (lobby.players.length >= MAX_LOBBY_PLAYERS) {
throw new ConflictError("Lobby is full");
}
const player = await addPlayer(lobby.id, userId, MAX_LOBBY_PLAYERS);
if (!player) {
// Race fallback: another request filled the last slot, started the game,
// or the user joined concurrently. Pre-checks above handle the common cases.
throw new ConflictError("Lobby is no longer available");
}
const fresh = await getLobbyByCodeWithPlayers(code);
if (!fresh) {
throw new AppError("Lobby disappeared during join", 500);
}
return fresh;
};