diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 306cc78..a48cee1 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -8,6 +8,9 @@ jobs: build-and-deploy: runs-on: docker steps: + - name: Install tools + run: apt-get update && apt-get install -y docker.io openssh-client + - name: Checkout code uses: https://data.forgejo.org/actions/checkout@v4 diff --git a/apps/api/package.json b/apps/api/package.json index bfd2878..155b859 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "pnpm --filter shared build && pnpm --filter db build && tsx watch src/server.ts", + "dev": "tsx watch src/server.ts", "build": "tsc", "start": "node dist/src/server.js", "test": "vitest" @@ -14,14 +14,12 @@ "@lila/shared": "workspace:*", "better-auth": "^1.6.2", "cors": "^2.8.6", - "express": "^5.2.1", - "ws": "^8.20.0" + "express": "^5.2.1" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/supertest": "^7.2.0", - "@types/ws": "^8.18.1", "supertest": "^7.2.2", "tsx": "^4.21.0" } diff --git a/apps/api/src/controllers/gameController.test.ts b/apps/api/src/controllers/gameController.test.ts index a4eb176..9746328 100644 --- a/apps/api/src/controllers/gameController.test.ts +++ b/apps/api/src/controllers/gameController.test.ts @@ -1,11 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import request from "supertest"; -import type { GameSession, AnswerResult } from "@lila/shared"; - -type SuccessResponse = { success: true; data: T }; -type ErrorResponse = { success: false; error: string }; -type GameStartResponse = SuccessResponse; -type GameAnswerResponse = SuccessResponse; vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() })); @@ -39,48 +33,49 @@ beforeEach(() => { describe("POST /api/v1/game/start", () => { it("returns 200 with a valid game session", async () => { const res = await request(app).post("/api/v1/game/start").send(validBody); - const body = res.body as GameStartResponse; + expect(res.status).toBe(200); - expect(body.success).toBe(true); - expect(body.data.sessionId).toBeDefined(); - expect(body.data.questions).toHaveLength(3); + expect(res.body.success).toBe(true); + expect(res.body.data.sessionId).toBeDefined(); + expect(res.body.data.questions).toHaveLength(3); }); it("returns 400 when the body is empty", async () => { const res = await request(app).post("/api/v1/game/start").send({}); - const body = res.body as ErrorResponse; + expect(res.status).toBe(400); - expect(body.success).toBe(false); - expect(body.error).toBeDefined(); + expect(res.body.success).toBe(false); + expect(res.body.error).toBeDefined(); }); it("returns 400 when required fields are missing", async () => { const res = await request(app) .post("/api/v1/game/start") .send({ source_language: "en" }); - const body = res.body as ErrorResponse; + expect(res.status).toBe(400); - expect(body.success).toBe(false); + expect(res.body.success).toBe(false); }); it("returns 400 when a field has an invalid value", async () => { const res = await request(app) .post("/api/v1/game/start") .send({ ...validBody, difficulty: "impossible" }); - const body = res.body as ErrorResponse; + expect(res.status).toBe(400); - expect(body.success).toBe(false); + expect(res.body.success).toBe(false); }); }); describe("POST /api/v1/game/answer", () => { it("returns 200 with an answer result for a valid submission", async () => { + // Start a game first const startRes = await request(app) .post("/api/v1/game/start") .send(validBody); - const startBody = startRes.body as GameStartResponse; - const { sessionId, questions } = startBody.data; - const question = questions[0]!; + + const { sessionId, questions } = startRes.body.data; + const question = questions[0]; const res = await request(app) .post("/api/v1/game/answer") @@ -89,20 +84,20 @@ describe("POST /api/v1/game/answer", () => { questionId: question.questionId, selectedOptionId: 0, }); - const body = res.body as GameAnswerResponse; + expect(res.status).toBe(200); - expect(body.success).toBe(true); - expect(body.data.questionId).toBe(question.questionId); - expect(typeof body.data.isCorrect).toBe("boolean"); - expect(typeof body.data.correctOptionId).toBe("number"); - expect(body.data.selectedOptionId).toBe(0); + expect(res.body.success).toBe(true); + expect(res.body.data.questionId).toBe(question.questionId); + expect(typeof res.body.data.isCorrect).toBe("boolean"); + expect(typeof res.body.data.correctOptionId).toBe("number"); + expect(res.body.data.selectedOptionId).toBe(0); }); it("returns 400 when the body is empty", async () => { const res = await request(app).post("/api/v1/game/answer").send({}); - const body = res.body as ErrorResponse; + expect(res.status).toBe(400); - expect(body.success).toBe(false); + expect(res.body.success).toBe(false); }); it("returns 404 when the session does not exist", async () => { @@ -113,18 +108,18 @@ describe("POST /api/v1/game/answer", () => { questionId: "00000000-0000-0000-0000-000000000000", selectedOptionId: 0, }); - const body = res.body as ErrorResponse; + expect(res.status).toBe(404); - expect(body.success).toBe(false); - expect(body.error).toContain("Game session not found"); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain("Game session not found"); }); it("returns 404 when the question does not exist in the session", async () => { const startRes = await request(app) .post("/api/v1/game/start") .send(validBody); - const startBody = startRes.body as GameStartResponse; - const { sessionId } = startBody.data; + + const { sessionId } = startRes.body.data; const res = await request(app) .post("/api/v1/game/answer") @@ -133,9 +128,9 @@ describe("POST /api/v1/game/answer", () => { questionId: "00000000-0000-0000-0000-000000000000", selectedOptionId: 0, }); - const body = res.body as ErrorResponse; + expect(res.status).toBe(404); - expect(body.success).toBe(false); - expect(body.error).toContain("Question not found"); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain("Question not found"); }); }); diff --git a/apps/api/src/controllers/lobbyController.ts b/apps/api/src/controllers/lobbyController.ts deleted file mode 100644 index 113c8c5..0000000 --- a/apps/api/src/controllers/lobbyController.ts +++ /dev/null @@ -1,37 +0,0 @@ -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); - } -}; diff --git a/apps/api/src/errors/AppError.ts b/apps/api/src/errors/AppError.ts index 4677d9f..6611b9b 100644 --- a/apps/api/src/errors/AppError.ts +++ b/apps/api/src/errors/AppError.ts @@ -19,9 +19,3 @@ export class NotFoundError extends AppError { super(message, 404); } } - -export class ConflictError extends AppError { - constructor(message: string) { - super(message, 409); - } -} diff --git a/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts b/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts index f29ca59..d4a339c 100644 --- a/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts +++ b/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts @@ -3,17 +3,15 @@ import type { GameSessionStore, GameSessionData } from "./GameSessionStore.js"; export class InMemoryGameSessionStore implements GameSessionStore { private sessions = new Map(); - create(sessionId: string, data: GameSessionData): Promise { + async create(sessionId: string, data: GameSessionData): Promise { this.sessions.set(sessionId, data); - return Promise.resolve(); } - get(sessionId: string): Promise { - return Promise.resolve(this.sessions.get(sessionId) ?? null); + async get(sessionId: string): Promise { + return this.sessions.get(sessionId) ?? null; } - delete(sessionId: string): Promise { + async delete(sessionId: string): Promise { this.sessions.delete(sessionId); - return Promise.resolve(); } } diff --git a/apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts b/apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts deleted file mode 100644 index d3d00f2..0000000 --- a/apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { LobbyGameStore, LobbyGameData } from "./LobbyGameStore.js"; - -export class InMemoryLobbyGameStore implements LobbyGameStore { - private games = new Map(); - - create(lobbyId: string, data: LobbyGameData): Promise { - if (this.games.has(lobbyId)) { - throw new Error(`Game already exists for lobby: ${lobbyId}`); - } - this.games.set(lobbyId, data); - return Promise.resolve(); - } - - get(lobbyId: string): Promise { - return Promise.resolve(this.games.get(lobbyId) ?? null); - } - - set(lobbyId: string, data: LobbyGameData): Promise { - this.games.set(lobbyId, data); - return Promise.resolve(); - } - - delete(lobbyId: string): Promise { - this.games.delete(lobbyId); - return Promise.resolve(); - } -} diff --git a/apps/api/src/lobbyGameStore/LobbyGameStore.ts b/apps/api/src/lobbyGameStore/LobbyGameStore.ts deleted file mode 100644 index bf59c3b..0000000 --- a/apps/api/src/lobbyGameStore/LobbyGameStore.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { MultiplayerQuestion } from "../services/multiplayerGameService.js"; - -export type LobbyGameData = { - code: string; - questions: MultiplayerQuestion[]; - currentIndex: number; - // NOTE: Map types are used here for O(1) lookups in-process. - // When migrating to Valkey, convert to plain objects for JSON serialization. - playerAnswers: Map; // userId → selectedOptionId, null = timed out - scores: Map; // userId → running total -}; - -export interface LobbyGameStore { - create(lobbyId: string, data: LobbyGameData): Promise; - get(lobbyId: string): Promise; - set(lobbyId: string, data: LobbyGameData): Promise; - delete(lobbyId: string): Promise; -} diff --git a/apps/api/src/lobbyGameStore/index.ts b/apps/api/src/lobbyGameStore/index.ts deleted file mode 100644 index 67dc9a2..0000000 --- a/apps/api/src/lobbyGameStore/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { LobbyGameStore, LobbyGameData } from "./LobbyGameStore.js"; -export { InMemoryLobbyGameStore } from "./InMemoryLobbyGameStore.js"; diff --git a/apps/api/src/middleware/authMiddleware.ts b/apps/api/src/middleware/authMiddleware.ts index 744c5e4..da18d01 100644 --- a/apps/api/src/middleware/authMiddleware.ts +++ b/apps/api/src/middleware/authMiddleware.ts @@ -16,7 +16,5 @@ export const requireAuth = async ( return; } - req.session = session; - next(); }; diff --git a/apps/api/src/routes/apiRouter.ts b/apps/api/src/routes/apiRouter.ts index f5ebd01..6ad84eb 100644 --- a/apps/api/src/routes/apiRouter.ts +++ b/apps/api/src/routes/apiRouter.ts @@ -2,10 +2,8 @@ import express from "express"; import { Router } from "express"; import { healthRouter } from "./healthRouter.js"; import { gameRouter } from "./gameRouter.js"; -import { lobbyRouter } from "./lobbyRouter.js"; export const apiRouter: Router = express.Router(); apiRouter.use("/health", healthRouter); apiRouter.use("/game", gameRouter); -apiRouter.use("/lobbies", lobbyRouter); diff --git a/apps/api/src/routes/lobbyRouter.ts b/apps/api/src/routes/lobbyRouter.ts deleted file mode 100644 index 5bd82dd..0000000 --- a/apps/api/src/routes/lobbyRouter.ts +++ /dev/null @@ -1,14 +0,0 @@ -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); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index c2b6d34..86d05ed 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,13 +1,8 @@ -import { createServer } from "http"; import { createApp } from "./app.js"; -import { setupWebSocket } from "./ws/index.js"; const PORT = Number(process.env["PORT"] ?? 3000); const app = createApp(); -const server = createServer(app); - -setupWebSocket(server); app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); diff --git a/apps/api/src/services/lobbyService.ts b/apps/api/src/services/lobbyService.ts deleted file mode 100644 index 3e307ef..0000000 --- a/apps/api/src/services/lobbyService.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 => { - 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 => { - 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; -}; diff --git a/apps/api/src/services/multiplayerGameService.ts b/apps/api/src/services/multiplayerGameService.ts deleted file mode 100644 index 32727b1..0000000 --- a/apps/api/src/services/multiplayerGameService.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { randomUUID } from "crypto"; -import { getGameTerms, getDistractors } from "@lila/db"; -import type { - GameQuestion, - AnswerOption, - SupportedLanguageCode, - SupportedPos, - DifficultyLevel, -} from "@lila/shared"; - -// TODO(game-mode-slice): replace with lobby settings when mode selection lands -const MULTIPLAYER_DEFAULTS = { - sourceLanguage: "en" as SupportedLanguageCode, - targetLanguage: "it" as SupportedLanguageCode, - pos: "noun" as SupportedPos, - difficulty: "easy" as DifficultyLevel, - rounds: 3, -}; - -const shuffle = (array: T[]): T[] => { - const result = [...array]; - for (let i = result.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - const temp = result[i]!; - result[i] = result[j]!; - result[j] = temp; - } - return result; -}; - -export type MultiplayerQuestion = GameQuestion & { correctOptionId: number }; - -export const generateMultiplayerQuestions = async (): Promise< - MultiplayerQuestion[] -> => { - const correctAnswers = await getGameTerms( - MULTIPLAYER_DEFAULTS.sourceLanguage, - MULTIPLAYER_DEFAULTS.targetLanguage, - MULTIPLAYER_DEFAULTS.pos, - MULTIPLAYER_DEFAULTS.difficulty, - MULTIPLAYER_DEFAULTS.rounds, - ); - - const questions: MultiplayerQuestion[] = await Promise.all( - correctAnswers.map(async (correctAnswer) => { - const distractorTexts = await getDistractors( - correctAnswer.termId, - correctAnswer.targetText, - MULTIPLAYER_DEFAULTS.targetLanguage, - MULTIPLAYER_DEFAULTS.pos, - MULTIPLAYER_DEFAULTS.difficulty, - 3, - ); - - const optionTexts = [correctAnswer.targetText, ...distractorTexts]; - const shuffledTexts = shuffle(optionTexts); - const correctOptionId = shuffledTexts.indexOf(correctAnswer.targetText); - - const options: AnswerOption[] = shuffledTexts.map((text, index) => ({ - optionId: index, - text, - })); - - return { - questionId: randomUUID(), - prompt: correctAnswer.sourceText, - gloss: correctAnswer.sourceGloss, - options, - correctOptionId, - }; - }), - ); - - return questions; -}; diff --git a/apps/api/src/types/express.d.ts b/apps/api/src/types/express.d.ts deleted file mode 100644 index 2fe1ac8..0000000 --- a/apps/api/src/types/express.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Session, User } from "better-auth"; - -declare global { - namespace Express { - interface Request { - session?: { session: Session; user: User }; - } - } -} - -declare module "ws" { - interface WebSocket { - lobbyId?: string | undefined; - } -} - -export {}; diff --git a/apps/api/src/ws/auth.ts b/apps/api/src/ws/auth.ts deleted file mode 100644 index 75c3613..0000000 --- a/apps/api/src/ws/auth.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { IncomingMessage } from "http"; -import type { Duplex } from "stream"; -import type { WebSocketServer, WebSocket } from "ws"; -import { fromNodeHeaders } from "better-auth/node"; -import { auth } from "../lib/auth.js"; - -export const handleUpgrade = async ( - request: IncomingMessage, - socket: Duplex, - head: Buffer, - wss: WebSocketServer, -): Promise => { - try { - const session = await auth.api.getSession({ - headers: fromNodeHeaders(request.headers), - }); - - if (!session) { - socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); - socket.destroy(); - return; - } - - wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { - wss.emit("connection", ws, request, session); - }); - } catch (err) { - console.error("WebSocket auth error:", err); - socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n"); - socket.destroy(); - } -}; diff --git a/apps/api/src/ws/connections.ts b/apps/api/src/ws/connections.ts deleted file mode 100644 index 189be61..0000000 --- a/apps/api/src/ws/connections.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { WebSocket } from "ws"; - -// Map> -const connections = new Map>(); - -export const addConnection = ( - lobbyId: string, - userId: string, - ws: WebSocket, -): void => { - if (!connections.has(lobbyId)) { - connections.set(lobbyId, new Map()); - } - connections.get(lobbyId)!.set(userId, ws); -}; - -export const removeConnection = (lobbyId: string, userId: string): void => { - const lobby = connections.get(lobbyId); - if (!lobby) return; - lobby.delete(userId); - if (lobby.size === 0) { - connections.delete(lobbyId); - } -}; - -export const getConnections = (lobbyId: string): Map => { - return connections.get(lobbyId) ?? new Map(); -}; - -export const broadcastToLobby = ( - lobbyId: string, - message: unknown, - excludeUserId?: string, -): void => { - const lobby = connections.get(lobbyId); - if (!lobby) return; - const payload = JSON.stringify(message); - for (const [userId, ws] of lobby) { - if (excludeUserId && userId === excludeUserId) continue; - if (ws.readyState === ws.OPEN) { - ws.send(payload); - } - } -}; diff --git a/apps/api/src/ws/gameState.ts b/apps/api/src/ws/gameState.ts deleted file mode 100644 index 3591843..0000000 --- a/apps/api/src/ws/gameState.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { InMemoryLobbyGameStore } from "../lobbyGameStore/index.js"; - -export const lobbyGameStore = new InMemoryLobbyGameStore(); -export const timers = new Map(); diff --git a/apps/api/src/ws/handlers/gameHandlers.ts b/apps/api/src/ws/handlers/gameHandlers.ts deleted file mode 100644 index ee06ce8..0000000 --- a/apps/api/src/ws/handlers/gameHandlers.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { WebSocket } from "ws"; -import type { User } from "better-auth"; -import type { WsGameAnswer } from "@lila/shared"; -import { finishGame, getLobbyByCodeWithPlayers } from "@lila/db"; -import { broadcastToLobby, getConnections } from "../connections.js"; -import { lobbyGameStore, timers } from "../gameState.js"; -import { NotFoundError, ConflictError } from "../../errors/AppError.js"; - -export const handleGameAnswer = async ( - _ws: WebSocket, - msg: WsGameAnswer, - user: User, -): Promise => { - const state = await lobbyGameStore.get(msg.lobbyId); - if (!state) { - throw new NotFoundError("Game not found"); - } - - const currentQuestion = state.questions[state.currentIndex]; - if (!currentQuestion) { - throw new ConflictError("No active question"); - } - - // Reject stale answers - if (currentQuestion.questionId !== msg.questionId) { - throw new ConflictError("Answer is for wrong question"); - } - - // Reject duplicate answers - if (state.playerAnswers.has(user.id)) { - throw new ConflictError("Already answered this question"); - } - - // Store answer - state.playerAnswers.set(user.id, msg.selectedOptionId); - await lobbyGameStore.set(msg.lobbyId, state); - - // Check if all connected players have answered - const connected = getConnections(msg.lobbyId); - const allAnswered = [...connected.keys()].every((userId) => - state.playerAnswers.has(userId), - ); - - if (allAnswered) { - // Clear timer — no need to wait - const timer = timers.get(msg.lobbyId); - if (timer) { - clearTimeout(timer); - timers.delete(msg.lobbyId); - } - await resolveRound(msg.lobbyId, state.currentIndex, state.questions.length); - } -}; - -export const resolveRound = async ( - lobbyId: string, - questionIndex: number, - totalQuestions: number, -): Promise => { - const state = await lobbyGameStore.get(lobbyId); - if (!state) return; // lobby was deleted mid-round, nothing to do - - const currentQuestion = state.questions[questionIndex]; - if (!currentQuestion) return; - - // Fill null for any players who didn't answer (timed out) - const connected = getConnections(lobbyId); - for (const userId of connected.keys()) { - if (!state.playerAnswers.has(userId)) { - state.playerAnswers.set(userId, null); - } - } - - // Evaluate answers and update scores - const results: { - userId: string; - selectedOptionId: number | null; - isCorrect: boolean; - }[] = []; - - for (const [userId, selectedOptionId] of state.playerAnswers) { - const isCorrect = - selectedOptionId !== null && - selectedOptionId === currentQuestion.correctOptionId; - if (isCorrect) { - state.scores.set(userId, (state.scores.get(userId) ?? 0) + 1); - } - results.push({ userId, selectedOptionId, isCorrect }); - } - - // Build updated players array for broadcast - const players = [...state.scores.entries()].map(([userId, score]) => ({ - userId, - score, - lobbyId, - user: { id: userId, name: userId }, // name resolved below - })); - - // Resolve user names from DB - const lobby = await getLobbyByCodeWithPlayers(state.code); - - const namedPlayers = players.map((p) => { - const dbPlayer = lobby?.players.find((dp) => dp.userId === p.userId); - return { - ...p, - user: { id: p.userId, name: dbPlayer?.user.name ?? p.userId }, - }; - }); - - // Broadcast answer result - broadcastToLobby(lobbyId, { - type: "game:answer_result", - correctOptionId: currentQuestion.correctOptionId, - results, - players: namedPlayers, - }); - - // Save updated state - state.playerAnswers = new Map(); - state.currentIndex = questionIndex + 1; - await lobbyGameStore.set(lobbyId, state); - - const isLastRound = questionIndex + 1 >= totalQuestions; - - if (isLastRound) { - await endGame(lobbyId, state); - } else { - // Wait 3s then broadcast next question - setTimeout(() => { - void (async () => { - const fresh = await lobbyGameStore.get(lobbyId); - if (!fresh) return; - const nextQuestion = fresh.questions[fresh.currentIndex]; - if (!nextQuestion) return; - broadcastToLobby(lobbyId, { - type: "game:question", - question: { - questionId: nextQuestion.questionId, - prompt: nextQuestion.prompt, - gloss: nextQuestion.gloss, - options: nextQuestion.options, - }, - questionNumber: fresh.currentIndex + 1, - totalQuestions, - }); - // Restart timer for next round - const timer = setTimeout(() => { - void resolveRound(lobbyId, fresh.currentIndex, totalQuestions); - }, 15000); - timers.set(lobbyId, timer); - })(); - }, 3000); - } -}; - -const endGame = async ( - lobbyId: string, - state: Awaited> & {}, -): Promise => { - // Persist final scores to DB - await finishGame(lobbyId, state.scores); - - // Determine winners (handle ties) - const maxScore = Math.max(...state.scores.values()); - const winnerIds = [...state.scores.entries()] - .filter(([, score]) => score === maxScore) - .map(([userId]) => userId); - - // Build final players array - const lobby = await getLobbyByCodeWithPlayers(state.code); - - const players = [...state.scores.entries()].map(([userId, score]) => { - const dbPlayer = lobby?.players.find((p) => p.userId === userId); - return { - lobbyId, - userId, - score, - user: { id: userId, name: dbPlayer?.user.name ?? userId }, - }; - }); - - broadcastToLobby(lobbyId, { type: "game:finished", players, winnerIds }); - - // Clean up game state - await lobbyGameStore.delete(lobbyId); - timers.delete(lobbyId); -}; diff --git a/apps/api/src/ws/handlers/lobbyHandlers.ts b/apps/api/src/ws/handlers/lobbyHandlers.ts deleted file mode 100644 index 12a59bf..0000000 --- a/apps/api/src/ws/handlers/lobbyHandlers.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { WebSocket } from "ws"; -import type { User } from "better-auth"; -import type { WsLobbyJoin, WsLobbyLeave, WsLobbyStart } from "@lila/shared"; -import { - getLobbyByCodeWithPlayers, - deleteLobby, - removePlayer, - updateLobbyStatus, -} from "@lila/db"; -import { - addConnection, - getConnections, - removeConnection, - broadcastToLobby, -} from "../connections.js"; -import { NotFoundError, ConflictError } from "../../errors/AppError.js"; -import { generateMultiplayerQuestions } from "../../services/multiplayerGameService.js"; -import { lobbyGameStore, timers } from "../gameState.js"; -import { resolveRound } from "./gameHandlers.js"; - -export const handleLobbyJoin = async ( - ws: WebSocket, - msg: WsLobbyJoin, - user: User, -): Promise => { - // Load lobby and validate membership - const lobby = await getLobbyByCodeWithPlayers(msg.code); - if (!lobby) { - throw new NotFoundError("Lobby not found"); - } - - if (lobby.status !== "waiting") { - throw new ConflictError("Lobby is not in waiting state"); - } - - if (!lobby.players.some((p) => p.userId === user.id)) { - throw new ConflictError("You are not a member of this lobby"); - } - - // Register connection and tag the socket with lobbyId - addConnection(lobby.id, user.id, ws); - ws.lobbyId = lobby.id; - - // Broadcast updated lobby state to all players - broadcastToLobby(lobby.id, { type: "lobby:state", lobby }); -}; - -export const handleLobbyLeave = async ( - ws: WebSocket, - msg: WsLobbyLeave, - user: User, -): Promise => { - const lobby = await getLobbyByCodeWithPlayers(msg.lobbyId); - if (!lobby) return; - - removeConnection(msg.lobbyId, user.id); - ws.lobbyId = undefined; - - if (lobby.hostUserId === user.id) { - await deleteLobby(msg.lobbyId); - broadcastToLobby(msg.lobbyId, { - type: "error", - code: "LOBBY_CLOSED", - message: "Host left the lobby", - }); - for (const player of lobby.players) { - removeConnection(msg.lobbyId, player.userId); - } - } else { - await removePlayer(msg.lobbyId, user.id); - const updated = await getLobbyByCodeWithPlayers(lobby.code); - if (!updated) return; - broadcastToLobby(msg.lobbyId, { type: "lobby:state", lobby: updated }); - - // TODO(reconnection-slice): if lobby.status === 'in_progress', the game - // continues with remaining players. If only one player remains after this - // leave, end the game immediately and declare them winner. Currently we - // broadcast updated lobby state and let the game resolve naturally via - // timeouts — the disconnected player's answers will be null each round. - // When reconnection handling is added, this is the place to change. - } -}; - -export const handleLobbyStart = async ( - _ws: WebSocket, - msg: WsLobbyStart, - user: User, -): Promise => { - // Load lobby and validate - const lobby = await getLobbyByCodeWithPlayers(msg.lobbyId); - if (!lobby) { - throw new NotFoundError("Lobby not found"); - } - if (lobby.hostUserId !== user.id) { - throw new ConflictError("Only the host can start the game"); - } - if (lobby.status !== "waiting") { - throw new ConflictError("Game has already started"); - } - - // Check connected players, not DB players - const connected = getConnections(msg.lobbyId); - if (connected.size < 2) { - throw new ConflictError("At least 2 players must be connected to start"); - } - - // Generate questions - const questions = await generateMultiplayerQuestions(); - - // Initialize scores for all connected players - const scores = new Map(); - for (const userId of connected.keys()) { - scores.set(userId, 0); - } - - // Initialize game state - await lobbyGameStore.create(msg.lobbyId, { - code: lobby.code, - questions, - currentIndex: 0, - playerAnswers: new Map(), - scores, - }); - - // Update lobby status in DB - await updateLobbyStatus(msg.lobbyId, "in_progress"); - - // Broadcast first question - const firstQuestion = questions[0]!; - broadcastToLobby(msg.lobbyId, { - type: "game:question", - question: { - questionId: firstQuestion.questionId, - prompt: firstQuestion.prompt, - gloss: firstQuestion.gloss, - options: firstQuestion.options, - }, - questionNumber: 1, - totalQuestions: questions.length, - }); - - // Start 15s timer - startRoundTimer(msg.lobbyId, 0, questions.length); -}; - -const startRoundTimer = ( - lobbyId: string, - questionIndex: number, - totalQuestions: number, -): void => { - const timer = setTimeout(() => { - void resolveRound(lobbyId, questionIndex, totalQuestions).catch((err) => { - console.error("Error resolving round after timeout:", err); - }); - }, 15000); - timers.set(lobbyId, timer); -}; diff --git a/apps/api/src/ws/index.ts b/apps/api/src/ws/index.ts deleted file mode 100644 index 048b734..0000000 --- a/apps/api/src/ws/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { WebSocketServer } from "ws"; -import type { WebSocket } from "ws"; -import type { Server } from "http"; -import type { IncomingMessage } from "http"; -import { handleUpgrade } from "./auth.js"; -import { handleMessage, type AuthenticatedUser } from "./router.js"; -import { removeConnection } from "./connections.js"; -import { handleLobbyLeave } from "./handlers/lobbyHandlers.js"; - -export const setupWebSocket = (server: Server): WebSocketServer => { - const wss = new WebSocketServer({ noServer: true }); - - server.on("upgrade", (request, socket, head) => { - if (request.url !== "/ws") { - socket.destroy(); - return; - } - void handleUpgrade(request, socket, head, wss).catch((err) => { - console.error("WebSocket upgrade error:", err); - socket.destroy(); - }); - }); - - wss.on( - "connection", - (ws: WebSocket, _request: IncomingMessage, auth: AuthenticatedUser) => { - ws.on("message", (rawData) => { - void handleMessage(ws, rawData, auth).catch((err) => { - console.error( - `WebSocket message error for user ${auth.user.id}:`, - err, - ); - }); - }); - - ws.on("close", () => { - void handleDisconnect(ws, auth).catch((err) => { - console.error( - `WebSocket disconnect error for user ${auth.user.id}:`, - err, - ); - }); - }); - - ws.on("error", (err) => { - console.error(`WebSocket error for user ${auth.user.id}:`, err); - }); - }, - ); - - return wss; -}; - -const handleDisconnect = async ( - ws: WebSocket, - auth: AuthenticatedUser, -): Promise => { - if (!ws.lobbyId) return; // user connected but never joined a lobby - removeConnection(ws.lobbyId, auth.user.id); - await handleLobbyLeave( - ws, - { type: "lobby:leave", lobbyId: ws.lobbyId }, - auth.user, - ); -}; diff --git a/apps/api/src/ws/router.ts b/apps/api/src/ws/router.ts deleted file mode 100644 index 1a1dad4..0000000 --- a/apps/api/src/ws/router.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { WebSocket } from "ws"; -import type { Session, User } from "better-auth"; -import { WsClientMessageSchema } from "@lila/shared"; -import { - handleLobbyJoin, - handleLobbyLeave, - handleLobbyStart, -} from "./handlers/lobbyHandlers.js"; -import { handleGameAnswer } from "./handlers/gameHandlers.js"; -import { AppError } from "../errors/AppError.js"; - -export type AuthenticatedUser = { session: Session; user: User }; - -const sendError = (ws: WebSocket, code: string, message: string): void => { - ws.send(JSON.stringify({ type: "error", code, message })); -}; - -const assertExhaustive = (_: never): never => { - throw new Error("Unhandled message type"); -}; - -export const handleMessage = async ( - ws: WebSocket, - rawData: unknown, - auth: AuthenticatedUser, -): Promise => { - // Layer 1: parse and validate incoming message - let parsed: unknown; - try { - parsed = JSON.parse( - typeof rawData === "string" ? rawData : (rawData as Buffer).toString(), - ); - } catch { - ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" })); - return; - } - const result = WsClientMessageSchema.safeParse(parsed); - - if (!result.success) { - ws.send( - JSON.stringify({ type: "error", message: "Invalid message format" }), - ); - return; - } - - const msg = result.data; - - // Layer 2: dispatch to handler, catch and translate errors - try { - switch (msg.type) { - case "lobby:join": - await handleLobbyJoin(ws, msg, auth.user); - break; - case "lobby:leave": - await handleLobbyLeave(ws, msg, auth.user); - break; - case "lobby:start": - await handleLobbyStart(ws, msg, auth.user); - break; - case "game:answer": - await handleGameAnswer(ws, msg, auth.user); - break; - default: - assertExhaustive(msg); - } - } catch (err) { - if (err instanceof AppError) { - sendError(ws, err.name, err.message); - } else { - console.error("Unhandled WS error:", err); - sendError(ws, "INTERNAL_ERROR", "An unexpected error occurred"); - } - } -}; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index e7fe4b0..1448282 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -24,13 +24,9 @@ const RootLayout = () => { {session ? ( diff --git a/apps/web/src/routes/play.tsx b/apps/web/src/routes/play.tsx index 32db5c4..d55bff0 100644 --- a/apps/web/src/routes/play.tsx +++ b/apps/web/src/routes/play.tsx @@ -6,12 +6,9 @@ import { ScoreScreen } from "../components/game/ScoreScreen"; import { GameSetup } from "../components/game/GameSetup"; import { authClient } from "../lib/auth-client"; -type GameStartResponse = { success: true; data: GameSession }; -type GameAnswerResponse = { success: true; data: AnswerResult }; - -const API_URL = (import.meta.env["VITE_API_URL"] as string) || ""; - function Play() { + const API_URL = import.meta.env["VITE_API_URL"] || ""; + const [gameSession, setGameSession] = useState(null); const [isLoading, setIsLoading] = useState(false); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); @@ -20,15 +17,13 @@ function Play() { const startGame = useCallback(async (settings: GameRequest) => { setIsLoading(true); - const response = await fetch(`${API_URL}/api/v1/game/start`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(settings), }); - - const data = (await response.json()) as GameStartResponse; + const data = await response.json(); setGameSession(data.data); setCurrentQuestionIndex(0); setResults([]); @@ -60,7 +55,7 @@ function Play() { selectedOptionId: optionId, }), }); - const data = (await response.json()) as GameAnswerResponse; + const data = await response.json(); setCurrentResult(data.data); }; @@ -75,13 +70,7 @@ function Play() { if (!gameSession && !isLoading) { return (
- { - void startGame(settings).catch((err) => { - console.error("Start game error:", err); - }); - }} - /> +
); } @@ -110,15 +99,11 @@ function Play() { return (
{ - void handleAnswer(optionId).catch((err) => { - console.error("Answer error:", err); - }); - }} question={question} questionNumber={currentQuestionIndex + 1} totalQuestions={gameSession.questions.length} currentResult={currentResult} + onAnswer={handleAnswer} onNext={handleNext} />
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..92135eb --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,91 @@ +services: + caddy: + container_name: lila-caddy + image: caddy:2-alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - caddy_data:/data + - caddy_config:/config + restart: unless-stopped + depends_on: + api: + condition: service_healthy + networks: + - lila-network + + api: + container_name: lila-api + build: + context: . + dockerfile: ./apps/api/Dockerfile + target: runner + env_file: + - .env + restart: unless-stopped + healthcheck: + test: + ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"] + interval: 5s + timeout: 3s + retries: 5 + depends_on: + database: + condition: service_healthy + networks: + - lila-network + + web: + container_name: lila-web + build: + context: . + dockerfile: ./apps/web/Dockerfile + target: production + args: + VITE_API_URL: https://api.lilastudy.com + restart: unless-stopped + networks: + - lila-network + + database: + container_name: lila-database + image: postgres:18.3-alpine3.23 + env_file: + - .env + environment: + - PGDATA=/var/lib/postgresql/data + volumes: + - lila-db:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - lila-network + + forgejo: + container_name: lila-forgejo + image: codeberg.org/forgejo/forgejo:11 + volumes: + - forgejo-data:/data + environment: + - USER_UID=1000 + - USER_GID=1000 + ports: + - "2222:22" + restart: unless-stopped + networks: + - lila-network + +networks: + lila-network: + +volumes: + lila-db: + caddy_data: + caddy_config: + forgejo-data: diff --git a/docker-compose.yml b/docker-compose.yml index b661975..5903fa6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,12 +42,11 @@ services: volumes: - ./apps/api:/app/apps/api # Hot reload API code - ./packages/shared:/app/packages/shared # Hot reload shared - - ./packages/db:/app/packages/db - /app/node_modules restart: unless-stopped healthcheck: test: - ["CMD-SHELL", "wget -qO- http://localhost:3000/api/v1/health || exit 1"] + ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"] interval: 5s timeout: 3s retries: 5 @@ -67,7 +66,6 @@ services: - "5173:5173" volumes: - ./apps/web:/app/apps/web # Hot reload: local edits reflect immediately - - ./packages/shared:/app/packages/shared - /app/node_modules # Protect container's node_modules from being overwritten environment: - VITE_API_URL=http://localhost:3000 diff --git a/documentation/deployment.md b/documentation/deployment.md index e51400c..f95fea4 100644 --- a/documentation/deployment.md +++ b/documentation/deployment.md @@ -12,19 +12,19 @@ This document describes the production deployment of the lila vocabulary trainer ### Subdomain Routing -| Subdomain | Service | Container port | -| ------------------- | ------------------------------------- | -------------- | -| `lilastudy.com` | Frontend (nginx serving static files) | 80 | -| `api.lilastudy.com` | Express API | 3000 | -| `git.lilastudy.com` | Forgejo (web UI + container registry) | 3000 | +| Subdomain | Service | Container port | +|---|---|---| +| `lilastudy.com` | Frontend (nginx serving static files) | 80 | +| `api.lilastudy.com` | Express API | 3000 | +| `git.lilastudy.com` | Forgejo (web UI + container registry) | 3000 | ### Ports Exposed to the Internet -| Port | Service | -| ---- | -------------------------------- | -| 80 | Caddy (HTTP, redirects to HTTPS) | -| 443 | Caddy (HTTPS) | -| 2222 | Forgejo SSH (git clone/push) | +| Port | Service | +|---|---| +| 80 | Caddy (HTTP, redirects to HTTPS) | +| 443 | Caddy (HTTPS) | +| 2222 | Forgejo SSH (git clone/push) | All other services (Postgres, API, frontend) communicate only over the internal Docker network. @@ -225,9 +225,59 @@ Host git.lilastudy.com This allows standard git commands without specifying the port. +## CI/CD Pipeline + +Automated build and deploy via Forgejo Actions. On every push to `main`, the pipeline builds ARM64 images natively on the VPS, pushes them to the Forgejo registry, and restarts the app containers. + +### Components + +- **Forgejo Actions** — enabled by default, workflow files in `.forgejo/workflows/` +- **Forgejo Runner** — runs as a container (`lila-ci-runner`) on the VPS, uses the host's Docker socket to build images natively on ARM64 +- **Workflow file** — `.forgejo/workflows/deploy.yml` + +### Pipeline Steps + +1. Install Docker CLI and SSH client in the job container +2. Checkout the repository +3. Login to the Forgejo container registry +4. Build API image (target: `runner`) +5. Build Web image (target: `production`, with `VITE_API_URL` baked in) +6. Push both images to `git.lilastudy.com` +7. SSH into the VPS, pull new images, restart `api` and `web` containers, prune old images + +### Secrets (stored in Forgejo repo settings → Actions → Secrets) + +| Secret | Value | +|---|---| +| REGISTRY_USER | Forgejo username | +| REGISTRY_PASSWORD | Forgejo password | +| SSH_PRIVATE_KEY | Contents of `~/.ssh/ci-runner` on the VPS | +| SSH_HOST | VPS IP address | +| SSH_USER | `lila` | + +### Runner Configuration + +The runner config is at `/data/config.yml` inside the `lila-ci-runner` container. Key settings: + +- `docker_host: "automount"` — mounts the host Docker socket into job containers +- `valid_volumes: ["/var/run/docker.sock"]` — allows the socket mount +- `privileged: true` — required for Docker access from job containers +- `options: "--group-add 989"` — adds the host's docker group (GID 989) to job containers + +The runner command must explicitly reference the config file: + +```yaml +command: '/bin/sh -c "sleep 5; forgejo-runner -c /data/config.yml daemon"' +``` + +### Deploy Cycle + +Push to main → pipeline runs automatically (~2-5 min) → app is updated. No manual steps required. + +To manually trigger a re-run: go to the repo's Actions tab, click on the latest run, and use the re-run button. + ## Known Issues and Future Work -- **CI/CD**: Currently manual build-push-pull cycle. Plan: Forgejo Actions with a runner on the VPS building ARM images natively (eliminates QEMU cross-compilation) - **Backups**: Offsite backup storage (Hetzner Object Storage or similar) should be added - **Valkey**: Not in the production stack yet. Will be added when multiplayer requires session/room state - **Monitoring/logging**: No centralized logging or uptime monitoring configured diff --git a/documentation/game_modes.md b/documentation/game_modes.md deleted file mode 100644 index 22eff3d..0000000 --- a/documentation/game_modes.md +++ /dev/null @@ -1,83 +0,0 @@ -# Game Modes - -This document describes the planned game modes for lila. Each mode uses the same lobby system and vocabulary data but differs in how answers are submitted, scored, and how a winner is determined. - -The first multiplayer mode to implement is TBD. The lobby infrastructure (create, join, WebSocket connection) is mode-agnostic — adding a new mode means adding new game logic, not changing the lobby. - ---- - -## TV Quiz Show - -**Type:** Multiplayer -**Answer model:** Buzzer — first to press gets to answer -**Rounds:** Fixed (e.g. 10) - -A question appears for all players. The first player to buzz in gets to answer. If correct, they score a point. If wrong, other players may get a chance to answer (TBD: whether the question passes to the next buzzer or the round ends). The host or a timer controls the pace. - -Key difference from other modes: only one player answers per question. Speed of reaction matters as much as knowledge. - ---- - -## Race to the Top - -**Type:** Multiplayer -**Answer model:** Simultaneous — all players answer independently -**Rounds:** None — play until target score reached - -All players see the same question and answer independently. No fixed round count. The first player to reach a target number of correct answers wins (e.g. 20). Fast-paced and competitive. - -Open questions: what happens if two players hit the target on the same question? Tiebreaker by speed? Shared win? - ---- - -## Chain Link - -**Type:** Multiplayer -**Answer model:** Turn-based — one player at a time, in rotation -**Rounds:** None — play until a player fails - -Players answer in a fixed rotation: Player 1, Player 2, Player 3, then back to Player 1. Each player gets one question per turn. The game continues until a player answers incorrectly — that player is out (or the game ends). Last correct answerer wins, or the game simply ends on the first wrong answer. - -Key difference from other modes: turn-based, not simultaneous. Pressure builds as you wait for your turn. - -Open questions: does the player who answers wrong lose, or does the game just end? If the game continues, does it become elimination? - ---- - -## Elimination Round - -**Type:** Multiplayer -**Answer model:** Simultaneous — all players answer independently -**Rounds:** Continue until one player remains - -All players see the same question and answer simultaneously. Players who answer incorrectly are eliminated. Rounds continue until only one player is left standing. - -Open questions: what if everyone gets it wrong in the same round? Reset that round? Eliminate nobody? What if it comes down to two players and both get it wrong repeatedly? - ---- - -## Cooperative Challenge - -**Type:** Multiplayer -**Answer model:** TBD -**Rounds:** TBD - -Players work together rather than competing. Concept not yet defined. Possible ideas: shared team score with a target, each player contributes answers to a collective pool, or players take turns and the team survives as long as the chain doesn't break. - ---- - -## Single Player Extended - -**Type:** Singleplayer -**Answer model:** TBD -**Rounds:** TBD - -An expanded version of the current singleplayer quiz. Concept not yet defined. Possible ideas: longer sessions with increasing difficulty, mixed POS/language rounds, streak bonuses, progress tracking across sessions, or timed challenge mode. - ---- - -## Schema Impact - -The `lobbies` table includes a `game_mode` column (varchar) with values like `tv_quiz`, `race_to_top`, `chain_link`, `elimination`. Mode-specific settings (e.g. target score for Race to the Top) can be stored in a `settings` jsonb column if needed. - -The singleplayer modes (Single Player Extended) don't require a lobby — they extend the existing singleplayer flow. diff --git a/documentation/notes.md b/documentation/notes.md index c750683..998cabf 100644 --- a/documentation/notes.md +++ b/documentation/notes.md @@ -21,13 +21,18 @@ WARNING! Your credentials are stored unencrypted in '/home/languagedev/.docker/c Configure a credential helper to remove this warning. See https://docs.docker.com/go/credential-store/ -### docker containers on startup? - -laptop: verify if docker containers run on startup (they shouldnt) - ### vps setup - monitoring and logging (eg via chrootkit or rkhunter, logwatch/monit => mails daily with summary) +- ~~keep the vps clean (e.g. old docker images/containers)~~ ✅ CI/CD pipeline runs `docker image prune -f` after deploy + +### ~~cd/ci pipeline~~ ✅ RESOLVED + +Forgejo Actions with runner on VPS, Forgejo built-in container registry. See `deployment.md`. + +### ~~postgres backups~~ ✅ RESOLVED + +Daily pg_dump cron job, 7-day retention, dev laptop auto-sync via rsync. See `deployment.md`. ### try now option diff --git a/documentation/spec.md b/documentation/spec.md index 637da00..4bf2835 100644 --- a/documentation/spec.md +++ b/documentation/spec.md @@ -290,15 +290,15 @@ After completing a task: share the code, ask what to refactor and why. The LLM s ## 11. Post-MVP Ladder -| Phase | What it adds | Status | -| ------------------- | ----------------------------------------------------------------------- | ------ | -| Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB | ✅ | -| Deployment | Docker Compose, Caddy, Forgejo, CI/CD, Hetzner VPS | ✅ | -| Hardening (partial) | CI/CD pipeline, DB backups | ✅ | -| User Stats | Games played, score history, profile page | ❌ | -| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ❌ | -| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ❌ | -| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ | +| Phase | What it adds | Status | +| ----------------- | ------------------------------------------------------------------------------- | ------ | +| Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB | ✅ | +| Deployment | Docker Compose, Caddy, Forgejo, CI/CD, Hetzner VPS | ✅ | +| Hardening (partial) | CI/CD pipeline, DB backups | ✅ | +| User Stats | Games played, score history, profile page | ❌ | +| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ❌ | +| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ❌ | +| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ | ### Future Data Model Extensions (deferred, additive) diff --git a/eslint.config.mjs b/eslint.config.mjs index 31b3da2..f7b125e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,7 +13,6 @@ export default defineConfig([ "eslint.config.mjs", "**/*.config.ts", "routeTree.gen.ts", - "scripts/**", ]), eslint.configs.recommended, @@ -39,27 +38,6 @@ export default defineConfig([ }, { files: ["apps/web/src/routes/**/*.{ts,tsx}"], - rules: { - "react-refresh/only-export-components": "off", - "@typescript-eslint/only-throw-error": "off", - }, - }, - { - rules: { - "@typescript-eslint/no-unused-vars": [ - "error", - { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - }, - ], - }, - }, - { - // better-auth's createAuthClient return type is insufficiently typed upstream. - // This is a known issue: https://github.com/better-auth/better-auth/issues - files: ["apps/web/src/lib/auth-client.ts"], - rules: { "@typescript-eslint/no-unsafe-assignment": "off" }, + rules: { "react-refresh/only-export-components": "off" }, }, ]); diff --git a/packages/db/drizzle/0006_certain_adam_destine.sql b/packages/db/drizzle/0006_certain_adam_destine.sql deleted file mode 100644 index 04d62fd..0000000 --- a/packages/db/drizzle/0006_certain_adam_destine.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE TABLE "lobbies" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "code" varchar(10) NOT NULL, - "host_user_id" text NOT NULL, - "status" varchar(20) NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "lobbies_code_unique" UNIQUE("code"), - CONSTRAINT "lobby_status_check" CHECK ("lobbies"."status" IN ('waiting', 'in_progress', 'finished')) -); ---> statement-breakpoint -CREATE TABLE "lobby_players" ( - "lobby_id" uuid NOT NULL, - "user_id" text NOT NULL, - "score" integer DEFAULT 0 NOT NULL, - "joined_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "lobby_players_lobby_id_user_id_pk" PRIMARY KEY("lobby_id","user_id") -); ---> statement-breakpoint -ALTER TABLE "lobbies" ADD CONSTRAINT "lobbies_host_user_id_user_id_fk" FOREIGN KEY ("host_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "lobby_players" ADD CONSTRAINT "lobby_players_lobby_id_lobbies_id_fk" FOREIGN KEY ("lobby_id") REFERENCES "public"."lobbies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "lobby_players" ADD CONSTRAINT "lobby_players_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0005_snapshot.json b/packages/db/drizzle/meta/0005_snapshot.json index 1a57e01..cfbb350 100644 --- a/packages/db/drizzle/meta/0005_snapshot.json +++ b/packages/db/drizzle/meta/0005_snapshot.json @@ -110,8 +110,12 @@ "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -145,8 +149,12 @@ "name": "deck_terms_deck_id_decks_id_fk", "tableFrom": "deck_terms", "tableTo": "decks", - "columnsFrom": ["deck_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -154,8 +162,12 @@ "name": "deck_terms_term_id_terms_id_fk", "tableFrom": "deck_terms", "tableTo": "terms", - "columnsFrom": ["term_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "term_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -163,7 +175,10 @@ "compositePrimaryKeys": { "deck_terms_deck_id_term_id_pk": { "name": "deck_terms_deck_id_term_id_pk", - "columns": ["deck_id", "term_id"] + "columns": [ + "deck_id", + "term_id" + ] } }, "uniqueConstraints": {}, @@ -250,7 +265,10 @@ "unique_deck_name": { "name": "unique_deck_name", "nullsNotDistinct": false, - "columns": ["name", "source_language"] + "columns": [ + "name", + "source_language" + ] } }, "policies": {}, @@ -350,8 +368,12 @@ "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -361,7 +383,9 @@ "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, - "columns": ["token"] + "columns": [ + "token" + ] } }, "policies": {}, @@ -411,8 +435,12 @@ "name": "term_glosses_term_id_terms_id_fk", "tableFrom": "term_glosses", "tableTo": "terms", - "columnsFrom": ["term_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "term_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -422,7 +450,10 @@ "unique_term_gloss": { "name": "unique_term_gloss", "nullsNotDistinct": false, - "columns": ["term_id", "language_code"] + "columns": [ + "term_id", + "language_code" + ] } }, "policies": {}, @@ -457,8 +488,12 @@ "name": "term_topics_term_id_terms_id_fk", "tableFrom": "term_topics", "tableTo": "terms", - "columnsFrom": ["term_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "term_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -466,8 +501,12 @@ "name": "term_topics_topic_id_topics_id_fk", "tableFrom": "term_topics", "tableTo": "topics", - "columnsFrom": ["topic_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -475,7 +514,10 @@ "compositePrimaryKeys": { "term_topics_term_id_topic_id_pk": { "name": "term_topics_term_id_topic_id_pk", - "columns": ["term_id", "topic_id"] + "columns": [ + "term_id", + "topic_id" + ] } }, "uniqueConstraints": {}, @@ -549,7 +591,10 @@ "unique_source_id": { "name": "unique_source_id", "nullsNotDistinct": false, - "columns": ["source", "source_id"] + "columns": [ + "source", + "source_id" + ] } }, "policies": {}, @@ -605,7 +650,9 @@ "topics_slug_unique": { "name": "topics_slug_unique", "nullsNotDistinct": false, - "columns": ["slug"] + "columns": [ + "slug" + ] } }, "policies": {}, @@ -701,8 +748,12 @@ "name": "translations_term_id_terms_id_fk", "tableFrom": "translations", "tableTo": "terms", - "columnsFrom": ["term_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "term_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -712,7 +763,11 @@ "unique_translations": { "name": "unique_translations", "nullsNotDistinct": false, - "columns": ["term_id", "language_code", "text"] + "columns": [ + "term_id", + "language_code", + "text" + ] } }, "policies": {}, @@ -789,7 +844,9 @@ "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, - "columns": ["email"] + "columns": [ + "email" + ] } }, "policies": {}, @@ -870,5 +927,9 @@ "roles": {}, "policies": {}, "views": {}, - "_meta": { "columns": {}, "schemas": {}, "tables": {} } -} + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0006_snapshot.json b/packages/db/drizzle/meta/0006_snapshot.json deleted file mode 100644 index 4578a80..0000000 --- a/packages/db/drizzle/meta/0006_snapshot.json +++ /dev/null @@ -1,1003 +0,0 @@ -{ - "id": "66d16ffb-4cdd-4437-b82f-a9fb7ee7b243", - "prevId": "8f34bafa-cffc-4933-952f-64b46afa9c5c", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.account": { - "name": "account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "account_userId_idx": { - "name": "account_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "account_user_id_user_id_fk": { - "name": "account_user_id_user_id_fk", - "tableFrom": "account", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.deck_terms": { - "name": "deck_terms", - "schema": "", - "columns": { - "deck_id": { - "name": "deck_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "term_id": { - "name": "term_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "deck_terms_deck_id_decks_id_fk": { - "name": "deck_terms_deck_id_decks_id_fk", - "tableFrom": "deck_terms", - "tableTo": "decks", - "columnsFrom": ["deck_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "deck_terms_term_id_terms_id_fk": { - "name": "deck_terms_term_id_terms_id_fk", - "tableFrom": "deck_terms", - "tableTo": "terms", - "columnsFrom": ["term_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "deck_terms_deck_id_term_id_pk": { - "name": "deck_terms_deck_id_term_id_pk", - "columns": ["deck_id", "term_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.decks": { - "name": "decks", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_language": { - "name": "source_language", - "type": "varchar(10)", - "primaryKey": false, - "notNull": true - }, - "validated_languages": { - "name": "validated_languages", - "type": "varchar(10)[]", - "primaryKey": false, - "notNull": true, - "default": "'{}'" - }, - "type": { - "name": "type", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "idx_decks_type": { - "name": "idx_decks_type", - "columns": [ - { - "expression": "type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "source_language", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "unique_deck_name": { - "name": "unique_deck_name", - "nullsNotDistinct": false, - "columns": ["name", "source_language"] - } - }, - "policies": {}, - "checkConstraints": { - "source_language_check": { - "name": "source_language_check", - "value": "\"decks\".\"source_language\" IN ('en', 'it')" - }, - "validated_languages_check": { - "name": "validated_languages_check", - "value": "validated_languages <@ ARRAY['en', 'it']::varchar[]" - }, - "validated_languages_excludes_source": { - "name": "validated_languages_excludes_source", - "value": "NOT (\"decks\".\"source_language\" = ANY(\"decks\".\"validated_languages\"))" - }, - "deck_type_check": { - "name": "deck_type_check", - "value": "\"decks\".\"type\" IN ('grammar', 'media')" - } - }, - "isRLSEnabled": false - }, - "public.lobbies": { - "name": "lobbies", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "code": { - "name": "code", - "type": "varchar(10)", - "primaryKey": false, - "notNull": true - }, - "host_user_id": { - "name": "host_user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "lobbies_host_user_id_user_id_fk": { - "name": "lobbies_host_user_id_user_id_fk", - "tableFrom": "lobbies", - "tableTo": "user", - "columnsFrom": ["host_user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "lobbies_code_unique": { - "name": "lobbies_code_unique", - "nullsNotDistinct": false, - "columns": ["code"] - } - }, - "policies": {}, - "checkConstraints": { - "lobby_status_check": { - "name": "lobby_status_check", - "value": "\"lobbies\".\"status\" IN ('waiting', 'in_progress', 'finished')" - } - }, - "isRLSEnabled": false - }, - "public.lobby_players": { - "name": "lobby_players", - "schema": "", - "columns": { - "lobby_id": { - "name": "lobby_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "score": { - "name": "score", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "joined_at": { - "name": "joined_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "lobby_players_lobby_id_lobbies_id_fk": { - "name": "lobby_players_lobby_id_lobbies_id_fk", - "tableFrom": "lobby_players", - "tableTo": "lobbies", - "columnsFrom": ["lobby_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "lobby_players_user_id_user_id_fk": { - "name": "lobby_players_user_id_user_id_fk", - "tableFrom": "lobby_players", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "lobby_players_lobby_id_user_id_pk": { - "name": "lobby_players_lobby_id_user_id_pk", - "columns": ["lobby_id", "user_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.session": { - "name": "session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "session_userId_idx": { - "name": "session_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", - "nullsNotDistinct": false, - "columns": ["token"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.term_glosses": { - "name": "term_glosses", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "term_id": { - "name": "term_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "language_code": { - "name": "language_code", - "type": "varchar(10)", - "primaryKey": false, - "notNull": true - }, - "text": { - "name": "text", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "term_glosses_term_id_terms_id_fk": { - "name": "term_glosses_term_id_terms_id_fk", - "tableFrom": "term_glosses", - "tableTo": "terms", - "columnsFrom": ["term_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "unique_term_gloss": { - "name": "unique_term_gloss", - "nullsNotDistinct": false, - "columns": ["term_id", "language_code"] - } - }, - "policies": {}, - "checkConstraints": { - "language_code_check": { - "name": "language_code_check", - "value": "\"term_glosses\".\"language_code\" IN ('en', 'it')" - } - }, - "isRLSEnabled": false - }, - "public.term_topics": { - "name": "term_topics", - "schema": "", - "columns": { - "term_id": { - "name": "term_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "topic_id": { - "name": "topic_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "term_topics_term_id_terms_id_fk": { - "name": "term_topics_term_id_terms_id_fk", - "tableFrom": "term_topics", - "tableTo": "terms", - "columnsFrom": ["term_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "term_topics_topic_id_topics_id_fk": { - "name": "term_topics_topic_id_topics_id_fk", - "tableFrom": "term_topics", - "tableTo": "topics", - "columnsFrom": ["topic_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "term_topics_term_id_topic_id_pk": { - "name": "term_topics_term_id_topic_id_pk", - "columns": ["term_id", "topic_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.terms": { - "name": "terms", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "source": { - "name": "source", - "type": "varchar(50)", - "primaryKey": false, - "notNull": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "pos": { - "name": "pos", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "idx_terms_source_pos": { - "name": "idx_terms_source_pos", - "columns": [ - { - "expression": "source", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "pos", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "unique_source_id": { - "name": "unique_source_id", - "nullsNotDistinct": false, - "columns": ["source", "source_id"] - } - }, - "policies": {}, - "checkConstraints": { - "pos_check": { - "name": "pos_check", - "value": "\"terms\".\"pos\" IN ('noun', 'verb')" - } - }, - "isRLSEnabled": false - }, - "public.topics": { - "name": "topics", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "slug": { - "name": "slug", - "type": "varchar(50)", - "primaryKey": false, - "notNull": true - }, - "label": { - "name": "label", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "topics_slug_unique": { - "name": "topics_slug_unique", - "nullsNotDistinct": false, - "columns": ["slug"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.translations": { - "name": "translations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "term_id": { - "name": "term_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "language_code": { - "name": "language_code", - "type": "varchar(10)", - "primaryKey": false, - "notNull": true - }, - "text": { - "name": "text", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "cefr_level": { - "name": "cefr_level", - "type": "varchar(2)", - "primaryKey": false, - "notNull": false - }, - "difficulty": { - "name": "difficulty", - "type": "varchar(20)", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "idx_translations_lang": { - "name": "idx_translations_lang", - "columns": [ - { - "expression": "language_code", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "difficulty", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "cefr_level", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "term_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "translations_term_id_terms_id_fk": { - "name": "translations_term_id_terms_id_fk", - "tableFrom": "translations", - "tableTo": "terms", - "columnsFrom": ["term_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "unique_translations": { - "name": "unique_translations", - "nullsNotDistinct": false, - "columns": ["term_id", "language_code", "text"] - } - }, - "policies": {}, - "checkConstraints": { - "language_code_check": { - "name": "language_code_check", - "value": "\"translations\".\"language_code\" IN ('en', 'it')" - }, - "cefr_check": { - "name": "cefr_check", - "value": "\"translations\".\"cefr_level\" IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')" - }, - "difficulty_check": { - "name": "difficulty_check", - "value": "\"translations\".\"difficulty\" IN ('easy', 'intermediate', 'hard')" - } - }, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verification": { - "name": "verification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "verification_identifier_idx": { - "name": "verification_identifier_idx", - "columns": [ - { - "expression": "identifier", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { "columns": {}, "schemas": {}, "tables": {} } -} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 4ae9761..3613600 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -43,13 +43,6 @@ "when": 1776154563168, "tag": "0005_broad_mariko_yashida", "breakpoints": true - }, - { - "idx": 6, - "version": "7", - "when": 1776270391189, - "tag": "0006_certain_adam_destine", - "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/db/schema.ts b/packages/db/src/db/schema.ts index 11ea51b..7fb5cd3 100644 --- a/packages/db/src/db/schema.ts +++ b/packages/db/src/db/schema.ts @@ -9,7 +9,6 @@ import { primaryKey, index, boolean, - integer, } from "drizzle-orm/pg-core"; import { sql, relations } from "drizzle-orm"; @@ -20,7 +19,6 @@ import { CEFR_LEVELS, SUPPORTED_DECK_TYPES, DIFFICULTY_LEVELS, - LOBBY_STATUSES, } from "@lila/shared"; export const terms = pgTable( @@ -254,53 +252,12 @@ export const accountRelations = relations(account, ({ one }) => ({ user: one(user, { fields: [account.userId], references: [user.id] }), })); -export const lobbies = pgTable( - "lobbies", - { - id: uuid().primaryKey().defaultRandom(), - code: varchar({ length: 10 }).notNull().unique(), - hostUserId: text("host_user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - status: varchar({ length: 20 }).notNull().default("waiting"), - createdAt: timestamp("created_at", { withTimezone: true }) - .defaultNow() - .notNull(), - }, - (table) => [ - check( - "lobby_status_check", - sql`${table.status} IN (${sql.raw(LOBBY_STATUSES.map((s) => `'${s}'`).join(", "))})`, - ), - ], -); - -export const lobby_players = pgTable( - "lobby_players", - { - lobbyId: uuid("lobby_id") - .notNull() - .references(() => lobbies.id, { onDelete: "cascade" }), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - score: integer().notNull().default(0), - joinedAt: timestamp("joined_at", { withTimezone: true }) - .defaultNow() - .notNull(), - }, - (table) => [primaryKey({ columns: [table.lobbyId, table.userId] })], -); - -export const lobbyRelations = relations(lobbies, ({ one, many }) => ({ - host: one(user, { fields: [lobbies.hostUserId], references: [user.id] }), - players: many(lobby_players), -})); - -export const lobbyPlayersRelations = relations(lobby_players, ({ one }) => ({ - lobby: one(lobbies, { - fields: [lobby_players.lobbyId], - references: [lobbies.id], - }), - user: one(user, { fields: [lobby_players.userId], references: [user.id] }), -})); +/* + * INTENTIONAL DESIGN DECISIONS — see decisions.md for full reasoning + * + * source + source_id (terms): idempotency key per import pipeline + * display_name UNIQUE (users): multiplayer requires distinguishable names + * UNIQUE(term_id, language_code, text): allows synonyms, prevents exact duplicates + * updated_at omitted: misleading without a trigger to maintain it + * FK indexes: all FK columns covered, no sequential scans on joins + */ diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index baa05e0..cd261de 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -3,13 +3,11 @@ import { drizzle } from "drizzle-orm/node-postgres"; import { resolve } from "path"; import { fileURLToPath } from "url"; import { dirname } from "path"; -import * as schema from "./db/schema.js"; config({ path: resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env"), }); -export const db = drizzle(process.env["DATABASE_URL"]!, { schema }); +export const db = drizzle(process.env["DATABASE_URL"]!); export * from "./models/termModel.js"; -export * from "./models/lobbyModel.js"; diff --git a/packages/db/src/models/lobbyModel.ts b/packages/db/src/models/lobbyModel.ts deleted file mode 100644 index 7aa02d2..0000000 --- a/packages/db/src/models/lobbyModel.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { db } from "@lila/db"; -import { lobbies, lobby_players } from "@lila/db/schema"; -import { eq, and, sql } from "drizzle-orm"; - -import type { LobbyStatus } from "@lila/shared"; - -export type Lobby = typeof lobbies.$inferSelect; -export type LobbyPlayer = typeof lobby_players.$inferSelect; -export type LobbyWithPlayers = Lobby & { - players: (LobbyPlayer & { user: { id: string; name: string } })[]; -}; - -export const createLobby = async ( - code: string, - hostUserId: string, -): Promise => { - const [newLobby] = await db - .insert(lobbies) - .values({ code, hostUserId, status: "waiting" }) - .returning(); - - if (!newLobby) { - throw new Error("Failed to create lobby"); - } - - return newLobby; -}; - -export const getLobbyByCodeWithPlayers = async ( - code: string, -): Promise => { - return db.query.lobbies.findFirst({ - where: eq(lobbies.code, code), - with: { - players: { with: { user: { columns: { id: true, name: true } } } }, - }, - }); -}; - -export const updateLobbyStatus = async ( - lobbyId: string, - status: LobbyStatus, -): Promise => { - await db.update(lobbies).set({ status }).where(eq(lobbies.id, lobbyId)); -}; - -export const deleteLobby = async (lobbyId: string): Promise => { - await db.delete(lobbies).where(eq(lobbies.id, lobbyId)); -}; - -/** - * Atomically inserts a player into a lobby. Returns the new player row, - * or undefined if the insert was skipped because: - * - the lobby is at capacity, or - * - the lobby is not in 'waiting' status, or - * - the user is already in the lobby (PK conflict). - * - * Callers are expected to pre-check these conditions against a hydrated - * lobby state to produce specific error messages; the undefined return - * is a safety net for concurrent races. - */ -export const addPlayer = async ( - lobbyId: string, - userId: string, - maxPlayers: number, -): Promise => { - const result = await db.execute(sql` - INSERT INTO lobby_players (lobby_id, user_id) - SELECT ${lobbyId}::uuid, ${userId} - WHERE ( - SELECT COUNT(*) FROM lobby_players WHERE lobby_id = ${lobbyId}::uuid - ) < ${maxPlayers} - AND EXISTS ( - SELECT 1 FROM lobbies WHERE id = ${lobbyId}::uuid AND status = 'waiting' - ) - ON CONFLICT (lobby_id, user_id) DO NOTHING - `); - - if (!result.rowCount) return undefined; - const [player] = await db - .select() - .from(lobby_players) - .where( - and(eq(lobby_players.lobbyId, lobbyId), eq(lobby_players.userId, userId)), - ); - - return player; -}; - -export const removePlayer = async ( - lobbyId: string, - userId: string, -): Promise => { - await db - .delete(lobby_players) - .where( - and(eq(lobby_players.lobbyId, lobbyId), eq(lobby_players.userId, userId)), - ); -}; - -export const finishGame = async ( - lobbyId: string, - scoresByUser: Map, -): Promise => { - await db.transaction(async (tx) => { - for (const [userId, score] of scoresByUser) { - await tx - .update(lobby_players) - .set({ score }) - .where( - and( - eq(lobby_players.lobbyId, lobbyId), - eq(lobby_players.userId, userId), - ), - ); - } - await tx - .update(lobbies) - .set({ status: "finished" }) - .where(eq(lobbies.id, lobbyId)); - }); -}; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index e218a43..b0ae2f3 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -13,8 +13,3 @@ export const SUPPORTED_DECK_TYPES = ["grammar", "media"] as const; export const DIFFICULTY_LEVELS = ["easy", "intermediate", "hard"] as const; export type DifficultyLevel = (typeof DIFFICULTY_LEVELS)[number]; - -export const LOBBY_STATUSES = ["waiting", "in_progress", "finished"] as const; -export type LobbyStatus = (typeof LOBBY_STATUSES)[number]; - -export const MAX_LOBBY_PLAYERS = 4; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7dc79f5..ff5f988 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,2 @@ export * from "./constants.js"; export * from "./schemas/game.js"; -export * from "./schemas/lobby.js"; diff --git a/packages/shared/src/schemas/lobby.ts b/packages/shared/src/schemas/lobby.ts deleted file mode 100644 index b2e3c55..0000000 --- a/packages/shared/src/schemas/lobby.ts +++ /dev/null @@ -1,130 +0,0 @@ -import * as z from "zod"; - -import { LOBBY_STATUSES } from "../constants.js"; -import { GameQuestionSchema } from "./game.js"; - -export const LobbyPlayerSchema = z.object({ - lobbyId: z.uuid(), - userId: z.string(), - score: z.number().int().min(0), - user: z.object({ id: z.string(), name: z.string() }), -}); - -export type LobbyPlayer = z.infer; - -export const LobbySchema = z.object({ - id: z.uuid(), - code: z.string().min(1).max(10), - hostUserId: z.string(), - status: z.enum(LOBBY_STATUSES), - createdAt: z.iso.datetime(), - players: z.array(LobbyPlayerSchema), -}); - -export type Lobby = z.infer; - -export const JoinLobbyResponseSchema = LobbySchema; - -export type JoinLobbyResponse = z.infer; - -// ---------------------------------------------------------------------------- -// WebSocket: Client → Server -// ---------------------------------------------------------------------------- - -export const WsLobbyJoinSchema = z.object({ - type: z.literal("lobby:join"), - code: z.string().min(1).max(10), -}); - -export type WsLobbyJoin = z.infer; - -export const WsLobbyLeaveSchema = z.object({ - type: z.literal("lobby:leave"), - lobbyId: z.uuid(), -}); - -export type WsLobbyLeave = z.infer; - -export const WsLobbyStartSchema = z.object({ - type: z.literal("lobby:start"), - lobbyId: z.uuid(), -}); - -export type WsLobbyStart = z.infer; - -export const WsGameAnswerSchema = z.object({ - type: z.literal("game:answer"), - lobbyId: z.uuid(), - questionId: z.uuid(), - selectedOptionId: z.number().int().min(0).max(3), -}); - -export type WsGameAnswer = z.infer; - -export const WsClientMessageSchema = z.discriminatedUnion("type", [ - WsLobbyJoinSchema, - WsLobbyLeaveSchema, - WsLobbyStartSchema, - WsGameAnswerSchema, -]); -export type WsClientMessage = z.infer; - -// ---------------------------------------------------------------------------- -// WebSocket: Server → Client -// ---------------------------------------------------------------------------- - -export const WsLobbyStateSchema = z.object({ - type: z.literal("lobby:state"), - lobby: LobbySchema, -}); - -export type WsLobbyState = z.infer; - -export const WsGameQuestionSchema = z.object({ - type: z.literal("game:question"), - question: GameQuestionSchema, - questionNumber: z.number().int().min(1), - totalQuestions: z.number().int().min(1), -}); - -export type WsGameQuestion = z.infer; - -export const WsGameAnswerResultSchema = z.object({ - type: z.literal("game:answer_result"), - correctOptionId: z.number().int().min(0).max(3), - results: z.array( - z.object({ - userId: z.string(), - selectedOptionId: z.number().int().min(0).max(3).nullable(), - isCorrect: z.boolean(), - }), - ), - players: z.array(LobbyPlayerSchema), -}); - -export type WsGameAnswerResult = z.infer; - -export const WsGameFinishedSchema = z.object({ - type: z.literal("game:finished"), - players: z.array(LobbyPlayerSchema), - winnerIds: z.array(z.string()), -}); - -export type WsGameFinished = z.infer; - -export const WsErrorSchema = z.object({ - type: z.literal("error"), - code: z.string(), - message: z.string(), -}); -export type WsError = z.infer; - -export const WsServerMessageSchema = z.discriminatedUnion("type", [ - WsLobbyStateSchema, - WsGameQuestionSchema, - WsGameAnswerResultSchema, - WsGameFinishedSchema, - WsErrorSchema, -]); - -export type WsServerMessage = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eaea759..11520d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,9 +62,6 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 - ws: - specifier: ^8.20.0 - version: 8.20.0 devDependencies: '@types/cors': specifier: ^2.8.19 @@ -75,9 +72,6 @@ importers: '@types/supertest': specifier: ^7.2.0 version: 7.2.0 - '@types/ws': - specifier: ^8.18.1 - version: 8.18.1 supertest: specifier: ^7.2.2 version: 7.2.2 @@ -1317,9 +1311,6 @@ packages: '@types/supertest@7.2.0': resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==} - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/eslint-plugin@8.57.1': resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2992,18 +2983,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - xlsx@0.18.5: resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} engines: {node: '>=0.8'} @@ -3936,10 +3915,6 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 - '@types/ws@8.18.1': - dependencies: - '@types/node': 24.12.0 - '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5611,8 +5586,6 @@ snapshots: wrappy@1.0.2: {} - ws@8.20.0: {} - xlsx@0.18.5: dependencies: adler-32: 1.3.1 diff --git a/scripts/create-issues.sh b/scripts/create-issues.sh deleted file mode 100644 index fefb072..0000000 --- a/scripts/create-issues.sh +++ /dev/null @@ -1,280 +0,0 @@ -#!/bin/bash - -# Forgejo batch issue creator for lila -# Usage: FORGEJO_TOKEN=your_token ./create-issues.sh - -FORGEJO_URL="https://git.lilastudy.com" -OWNER="forgejo-lila" -REPO="lila" -TOKEN="${FORGEJO_TOKEN:?Set FORGEJO_TOKEN environment variable}" - -API="${FORGEJO_URL}/api/v1/repos/${OWNER}/${REPO}" - -# Helper: create a label (ignores if already exists) -create_label() { - local name="$1" color="$2" description="$3" - curl -s -X POST "${API}/labels" \ - -H "Authorization: token ${TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"name\":\"${name}\",\"color\":\"${color}\",\"description\":\"${description}\"}" > /dev/null - echo "Label: ${name}" -} - -# Helper: create an issue with labels -create_issue() { - local title="$1" body="$2" - shift 2 - local labels="$*" - - # Build labels JSON array - local label_ids="" - for label in $labels; do - local id - id=$(curl -s "${API}/labels" \ - -H "Authorization: token ${TOKEN}" | \ - python3 -c "import sys,json; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='${label}']") - if [ -n "$label_ids" ]; then - label_ids="${label_ids},${id}" - else - label_ids="${id}" - fi - done - - curl -s -X POST "${API}/issues" \ - -H "Authorization: token ${TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"title\":$(echo "$title" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))'),\"body\":$(echo "$body" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))'),\"labels\":[${label_ids}]}" > /dev/null - - echo "Issue: ${title}" -} - -echo "=== Creating labels ===" -create_label "feature" "#0075ca" "New user-facing functionality" -create_label "infra" "#e4e669" "Infrastructure, deployment, DevOps" -create_label "debt" "#d876e3" "Technical cleanup, refactoring" -create_label "security" "#b60205" "Security improvements" -create_label "ux" "#1d76db" "User experience, accessibility, polish" -create_label "multiplayer" "#0e8a16" "Multiplayer lobby and game features" - -echo "" -echo "=== Creating issues ===" - -# ── feature ── - -create_issue \ - "Add guest/try-now option — play without account" \ - "Allow users to play a quiz without signing in so they can see what the app offers before creating an account. Make auth middleware optional on game routes, add a 'Try without account' button on the login/landing page." \ - feature - -create_issue \ - "Add Apple login provider" \ - "Add Apple as a social login option via Better Auth. Requires Apple Developer account and Sign in with Apple configuration." \ - feature - -create_issue \ - "Add email+password login" \ - "Add traditional email and password authentication as an alternative to social login. Configure via Better Auth." \ - feature - -create_issue \ - "User stats endpoint + profile page" \ - "Add GET /users/me/stats endpoint returning games played, score history, etc. Build a frontend profile page displaying the stats." \ - feature - -# ── infra ── - -create_issue \ - "Google OAuth app verification and publishing" \ - "Currently only test users can log in via Google. Publish the OAuth consent screen so any Google user can sign in. Requires branding verification through Google Cloud Console." \ - infra - -create_issue \ - "Set up Docker credential helper on dev laptop" \ - "Docker credentials are stored unencrypted in ~/.docker/config.json. Set up a credential helper to store them securely. See https://docs.docker.com/go/credential-store/" \ - infra - -create_issue \ - "VPS monitoring and logging" \ - "Set up monitoring and centralized logging on the VPS. Options: chkrootkit/rkhunter for security, logwatch/monit for daily summaries, uptime monitoring for service health." \ - infra - -create_issue \ - "Move to offsite backup storage" \ - "Currently database backups live on the same VPS. Add offsite copies to Hetzner Object Storage or similar S3-compatible service to protect against VPS failure." \ - infra - -create_issue \ - "Replace in-memory game session store with Valkey" \ - "Add Valkey container to the production Docker stack. Implement ValkeyGameSessionStore using the existing GameSessionStore interface. Required before multiplayer." \ - infra - -create_issue \ - "Modern env management approach" \ - "Evaluate replacing .env files with a more robust approach (e.g. dotenvx, infisical, or similar). Current setup works but .env files are error-prone and not versioned." \ - infra - -create_issue \ - "Pin dependencies in package.json files" \ - "Pin all dependency versions in package.json files to exact versions to prevent unexpected updates from breaking builds." \ - infra - -# ── debt ── - -create_issue \ - "Rethink organization of datafiles and wordlists" \ - "The current layout of data-sources/, scripts/datafiles/, scripts/data-sources/, and packages/db/src/data/ is confusing with overlapping content. Consolidate into a clear structure." \ - debt - -create_issue \ - "Resolve eslint peer dependency warning" \ - "eslint-plugin-react-hooks 7.0.1 expects eslint ^3.0.0-^9.0.0 but found 10.0.3. Resolve the peer dependency mismatch." \ - debt - -# ── security ── - -create_issue \ - "Rate limiting on API endpoints" \ - "Add rate limiting to prevent abuse. At minimum: auth endpoints (brute force prevention), game endpoints (spam prevention). Consider express-rate-limit or similar." \ - security - -# ── ux ── - -create_issue \ - "404/redirect handling for unknown routes and subdomains" \ - "Unknown routes return raw errors. Add a catch-all route on the frontend for client-side 404s. Consider Caddy fallback for unrecognized subdomains." \ - ux - -create_issue \ - "React error boundaries" \ - "Add error boundaries to catch and display runtime errors gracefully instead of crashing the entire app." \ - ux - -create_issue \ - "Accessibility pass" \ - "Keyboard navigation for quiz buttons, ARIA labels on interactive elements, focus management during quiz flow." \ - ux - -create_issue \ - "Favicon, page titles, Open Graph meta" \ - "Add favicon, set proper page titles per route, add Open Graph meta tags for link previews when sharing." \ - ux - -# ── multiplayer ── - -create_issue \ - "Drizzle schema: lobbies, lobby_players + migration" \ - "Create lobbies table (id, code, host_user_id, status, is_private, game_mode, settings, created_at) and lobby_players table (lobby_id, user_id, score, joined_at). Run migration. See game-modes.md for game_mode values." \ - multiplayer - -create_issue \ - "REST endpoints: POST /lobbies, POST /lobbies/:code/join" \ - "Create lobby (generates short code, sets host) and join lobby (validates code, adds player, enforces max limit)." \ - multiplayer - -create_issue \ - "LobbyService: create lobby, join lobby, enforce player limit" \ - "Service layer for lobby management. Generate human-readable codes, validate join requests, track lobby state. Public lobbies are browsable, private lobbies require code." \ - multiplayer - -create_issue \ - "WebSocket server: attach ws upgrade to Express" \ - "Attach ws library upgrade handler to the existing Express HTTP server. Handle connection lifecycle." \ - multiplayer - -create_issue \ - "WS auth middleware: validate session on upgrade" \ - "Validate Better Auth session on WebSocket upgrade request. Reject unauthenticated connections." \ - multiplayer - -create_issue \ - "WS message router: dispatch by type" \ - "Route incoming WebSocket messages by their type field to the appropriate handler. Use Zod discriminated union for type safety." \ - multiplayer - -create_issue \ - "Lobby join/leave handlers + broadcast lobby state" \ - "Handle lobby:join and lobby:leave WebSocket events. Broadcast updated player list to all connected players in the lobby." \ - multiplayer - -create_issue \ - "Lobby state in Valkey (ephemeral) + PostgreSQL (durable)" \ - "Store live lobby state (connected players, current question, timer) in Valkey. Store durable records (who played, final scores) in PostgreSQL." \ - multiplayer - -create_issue \ - "WS event Zod schemas in packages/shared" \ - "Define all WebSocket message types as Zod discriminated unions in packages/shared. Covers lobby events (join, leave, start) and game events (question, answer, result, finished)." \ - multiplayer - -create_issue \ - "Frontend: lobby browser + create/join lobby" \ - "Lobby list showing public open lobbies. Create lobby form (game mode, public/private). Join-by-code input for private lobbies." \ - multiplayer - -create_issue \ - "Frontend: lobby view (player list, code, start game)" \ - "Show lobby code, connected players, game mode. Host sees Start Game button. Players see waiting state. Real-time updates via WebSocket." \ - multiplayer - -create_issue \ - "Frontend: WS client singleton with reconnect" \ - "WebSocket client that maintains a single connection, handles reconnection on disconnect, and dispatches incoming messages to the appropriate state handlers." \ - multiplayer - -create_issue \ - "GameService: question sequence + server timer" \ - "Generate question sequence for a lobby game. Enforce per-question timer (e.g. 15s). Timer logic varies by game mode — see game-modes.md." \ - multiplayer - -create_issue \ - "lobby:start WS handler — broadcast first question" \ - "When host starts the game, generate questions, change lobby status to in_progress, broadcast first question to all players." \ - multiplayer - -create_issue \ - "game:answer WS handler — collect answers" \ - "Receive player answers via WebSocket. Track who has answered. Behavior varies by game mode (simultaneous vs turn-based vs buzzer)." \ - multiplayer - -create_issue \ - "Answer evaluation + broadcast results" \ - "On all-answered or timeout: evaluate answers, calculate scores, broadcast game:answer_result to all players. Then send next question or end game." \ - multiplayer - -create_issue \ - "Game finished: broadcast results, update DB" \ - "After final round: broadcast game:finished with final scores and winner. Write game results to PostgreSQL (transactional). Change lobby status to finished." \ - multiplayer - -create_issue \ - "Frontend: multiplayer game route" \ - "Route for active multiplayer games. Receives questions and results via WebSocket. Reuses QuestionCard and OptionButton components." \ - multiplayer - -create_issue \ - "Frontend: countdown timer component" \ - "Visual countdown timer synchronized with server timer. Shows remaining seconds per question." \ - multiplayer - -create_issue \ - "Frontend: ScoreBoard component (live per-player scores)" \ - "Displays live scores for all players during a multiplayer game. Updates in real-time via WebSocket." \ - multiplayer - -create_issue \ - "Frontend: GameFinished screen" \ - "Winner highlight, final scores, play again option. Returns to lobby on play again." \ - multiplayer - -create_issue \ - "Multiplayer GameService unit tests" \ - "Unit tests for round evaluation, scoring, tie-breaking, timeout handling across different game modes." \ - multiplayer - -create_issue \ - "Graceful WS reconnect with exponential back-off" \ - "Handle WebSocket disconnections gracefully. Reconnect with exponential back-off. Restore game state on reconnection if game is still in progress." \ - multiplayer - -echo "" -echo "=== Done ==="