const http = require("http"); const { Pool } = require("pg"); const port = Number(process.env.API_INTERNAL_PORT || 3001); const serviceName = process.env.SERVICE_NAME || "video-games-api"; const databaseUrl = process.env.DATABASE_URL || "postgres://video_games_user:change_me@video-games-db:5432/video_games"; const pool = new Pool({ connectionString: databaseUrl, }); function sendJson(response, statusCode, payload) { response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" }); response.end(JSON.stringify(payload)); } async function readJsonBody(request) { const chunks = []; let size = 0; for await (const chunk of request) { size += chunk.length; if (size > 1024 * 1024) { throw new Error("Payload too large"); } chunks.push(chunk); } if (!chunks.length) { return {}; } return JSON.parse(Buffer.concat(chunks).toString("utf8")); } function normalizeText(value) { if (value == null) { return ""; } return String(value).trim(); } async function ensureConsole(client, brandNameRaw, consoleNameRaw) { const brandName = normalizeText(brandNameRaw).toUpperCase(); const consoleName = normalizeText(consoleNameRaw); if (!brandName || !consoleName) { throw new Error("brand and consoleName are required"); } const brandResult = await client.query( ` INSERT INTO brands(name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id; `, [brandName], ); const brandId = brandResult.rows[0].id; const consoleResult = await client.query( ` INSERT INTO consoles(brand_id, name) VALUES ($1, $2) ON CONFLICT (brand_id, name) DO UPDATE SET name = EXCLUDED.name RETURNING id; `, [brandId, consoleName], ); return { brand: brandName, consoleName, consoleId: consoleResult.rows[0].id, }; } async function runMigrations() { await pool.query("CREATE EXTENSION IF NOT EXISTS pgcrypto;"); await pool.query(` CREATE TABLE IF NOT EXISTS brands ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); `); await pool.query(` CREATE TABLE IF NOT EXISTS consoles ( id BIGSERIAL PRIMARY KEY, brand_id BIGINT NOT NULL REFERENCES brands(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (brand_id, name) ); `); await pool.query(` CREATE TABLE IF NOT EXISTS games ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), console_id BIGINT NOT NULL REFERENCES consoles(id) ON DELETE CASCADE, title TEXT NOT NULL, genre TEXT, publisher TEXT, release_year INTEGER CHECK (release_year IS NULL OR (release_year >= 1970 AND release_year <= 2100)), estimated_value NUMERIC(10,2) CHECK (estimated_value IS NULL OR estimated_value >= 0), loaned_to TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); `); await pool.query(` CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; `); await pool.query(` DROP TRIGGER IF EXISTS trg_games_updated_at ON games; CREATE TRIGGER trg_games_updated_at BEFORE UPDATE ON games FOR EACH ROW EXECUTE FUNCTION set_updated_at(); `); } async function querySingleValue(sqlText) { const result = await pool.query(sqlText); return Number(result.rows[0].count || 0); } async function getCatalogSummary() { const [brands, consoles, games] = await Promise.all([ querySingleValue("SELECT COUNT(*)::int AS count FROM brands;"), querySingleValue("SELECT COUNT(*)::int AS count FROM consoles;"), querySingleValue("SELECT COUNT(*)::int AS count FROM games;"), ]); return { brands, consoles, games }; } async function getCatalogTree() { const result = await pool.query(` SELECT b.id::int AS brand_id, b.name AS brand_name, c.id::int AS console_id, c.name AS console_name, COUNT(g.id)::int AS games_count FROM brands b LEFT JOIN consoles c ON c.brand_id = b.id LEFT JOIN games g ON g.console_id = c.id GROUP BY b.id, b.name, c.id, c.name ORDER BY b.name ASC, c.name ASC; `); const brandMap = new Map(); for (const row of result.rows) { if (!brandMap.has(row.brand_id)) { brandMap.set(row.brand_id, { id: row.brand_id, name: row.brand_name, consoles: [], }); } if (row.console_id) { brandMap.get(row.brand_id).consoles.push({ id: row.console_id, name: row.console_name, gamesCount: row.games_count, }); } } return Array.from(brandMap.values()); } async function getCatalogFull() { const brandConsoleRows = await pool.query(` SELECT b.name AS brand_name, c.name AS console_name FROM brands b LEFT JOIN consoles c ON c.brand_id = b.id ORDER BY b.name ASC, c.name ASC; `); const gameRows = await pool.query(` SELECT c.name AS console_name, g.id::text AS id, g.title, g.genre, g.publisher, g.release_year, g.estimated_value, g.loaned_to, g.created_at FROM games g JOIN consoles c ON c.id = g.console_id ORDER BY g.created_at DESC; `); const brands = {}; for (const row of brandConsoleRows.rows) { const brand = row.brand_name; brands[brand] = brands[brand] || []; if (row.console_name && !brands[brand].includes(row.console_name)) { brands[brand].push(row.console_name); } } const gamesByConsole = {}; for (const row of gameRows.rows) { const consoleName = row.console_name; gamesByConsole[consoleName] = gamesByConsole[consoleName] || []; gamesByConsole[consoleName].push({ id: row.id, title: row.title, genre: row.genre || "", publisher: row.publisher || "", year: row.release_year || null, value: row.estimated_value != null ? Number(row.estimated_value) : null, loanedTo: row.loaned_to || "", createdAt: row.created_at, }); } return { brands, gamesByConsole }; } async function createConsole(brand, consoleName) { const client = await pool.connect(); try { await client.query("BEGIN"); const result = await ensureConsole(client, brand, consoleName); await client.query("COMMIT"); return result; } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } } async function createGame(payload) { const client = await pool.connect(); try { await client.query("BEGIN"); const consoleData = await ensureConsole(client, payload.brand, payload.consoleName); const title = normalizeText(payload.title); if (!title) { throw new Error("title is required"); } const genre = normalizeText(payload.genre) || null; const publisher = normalizeText(payload.publisher) || null; const loanedTo = normalizeText(payload.loanedTo) || null; const year = payload.year != null && payload.year !== "" ? Number(payload.year) : null; const value = payload.value != null && payload.value !== "" ? Number(payload.value) : null; const insertResult = await client.query( ` INSERT INTO games( console_id, title, genre, publisher, release_year, estimated_value, loaned_to ) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id::text AS id; `, [consoleData.consoleId, title, genre, publisher, year, value, loanedTo], ); await client.query("COMMIT"); return { id: insertResult.rows[0].id }; } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } } async function updateGame(id, payload) { const client = await pool.connect(); try { await client.query("BEGIN"); const consoleData = await ensureConsole(client, payload.brand, payload.consoleName); const title = normalizeText(payload.title); if (!title) { throw new Error("title is required"); } const genre = normalizeText(payload.genre) || null; const publisher = normalizeText(payload.publisher) || null; const loanedTo = normalizeText(payload.loanedTo) || null; const year = payload.year != null && payload.year !== "" ? Number(payload.year) : null; const value = payload.value != null && payload.value !== "" ? Number(payload.value) : null; const updateResult = await client.query( ` UPDATE games SET console_id = $2, title = $3, genre = $4, publisher = $5, release_year = $6, estimated_value = $7, loaned_to = $8 WHERE id = $1::uuid RETURNING id::text AS id; `, [id, consoleData.consoleId, title, genre, publisher, year, value, loanedTo], ); if (!updateResult.rowCount) { throw new Error("game not found"); } await client.query("COMMIT"); return { id: updateResult.rows[0].id }; } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } } async function deleteGame(id) { const result = await pool.query("DELETE FROM games WHERE id = $1::uuid;", [id]); return result.rowCount > 0; } async function toggleGameLoan(id) { const result = await pool.query( ` UPDATE games SET loaned_to = CASE WHEN COALESCE(loaned_to, '') = '' THEN 'A renseigner' ELSE NULL END WHERE id = $1::uuid RETURNING id::text AS id; `, [id], ); return result.rowCount > 0; } async function importCatalog(payload) { const brands = payload && payload.brands && typeof payload.brands === "object" ? payload.brands : {}; const gamesByConsole = payload && payload.gamesByConsole && typeof payload.gamesByConsole === "object" ? payload.gamesByConsole : {}; const client = await pool.connect(); let insertedConsoles = 0; let insertedGames = 0; try { await client.query("BEGIN"); for (const [brandNameRaw, consoles] of Object.entries(brands)) { if (!Array.isArray(consoles)) { continue; } for (const consoleNameRaw of consoles) { const brandName = normalizeText(brandNameRaw).toUpperCase(); const consoleName = normalizeText(consoleNameRaw); if (!brandName || !consoleName) { continue; } const existingConsole = await client.query( ` SELECT c.id FROM consoles c JOIN brands b ON b.id = c.brand_id WHERE b.name = $1 AND c.name = $2 LIMIT 1; `, [brandName, consoleName], ); const result = await ensureConsole(client, brandNameRaw, consoleNameRaw); if (result && result.consoleId && !existingConsole.rowCount) { insertedConsoles += 1; } } } for (const [consoleNameRaw, games] of Object.entries(gamesByConsole)) { if (!Array.isArray(games) || !games.length) { continue; } const consoleName = normalizeText(consoleNameRaw); if (!consoleName) { continue; } const consoleRow = await client.query( ` SELECT c.id, b.name AS brand_name FROM consoles c JOIN brands b ON b.id = c.brand_id WHERE c.name = $1 ORDER BY c.id ASC LIMIT 1; `, [consoleName], ); if (!consoleRow.rowCount) { continue; } const consoleId = consoleRow.rows[0].id; for (const game of games) { const title = normalizeText(game && game.title); if (!title) { continue; } const genre = normalizeText(game && game.genre) || null; const publisher = normalizeText(game && game.publisher) || null; const loanedTo = normalizeText(game && game.loanedTo) || null; const year = game && game.year != null && game.year !== "" ? Number(game.year) : null; const value = game && game.value != null && game.value !== "" ? Number(game.value) : null; const dedupeResult = await client.query( ` SELECT 1 FROM games WHERE console_id = $1 AND LOWER(title) = LOWER($2) AND COALESCE(release_year, 0) = COALESCE($3, 0) LIMIT 1; `, [consoleId, title, year], ); if (dedupeResult.rowCount) { continue; } await client.query( ` INSERT INTO games( console_id, title, genre, publisher, release_year, estimated_value, loaned_to ) VALUES ($1, $2, $3, $4, $5, $6, $7); `, [consoleId, title, genre, publisher, year, value, loanedTo], ); insertedGames += 1; } } await client.query("COMMIT"); return { insertedConsoles, insertedGames }; } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } } async function handleRequest(request, response) { const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`); if (request.method === "GET" && url.pathname === "/health") { try { await pool.query("SELECT 1;"); sendJson(response, 200, { status: "ok", service: serviceName, db: "up", timestamp: new Date().toISOString(), }); } catch (error) { sendJson(response, 503, { status: "degraded", service: serviceName, db: "down", error: error.message, }); } return; } if (request.method === "GET" && url.pathname === "/api/catalog/summary") { try { const summary = await getCatalogSummary(); sendJson(response, 200, summary); } catch (error) { sendJson(response, 500, { status: "error", message: error.message }); } return; } if (request.method === "GET" && url.pathname === "/api/catalog/tree") { try { const tree = await getCatalogTree(); sendJson(response, 200, { brands: tree }); } catch (error) { sendJson(response, 500, { status: "error", message: error.message }); } return; } if (request.method === "GET" && url.pathname === "/api/catalog/full") { try { const full = await getCatalogFull(); sendJson(response, 200, full); } catch (error) { sendJson(response, 500, { status: "error", message: error.message }); } return; } if (request.method === "POST" && url.pathname === "/api/catalog/consoles") { try { const body = await readJsonBody(request); const created = await createConsole(body.brand, body.consoleName); sendJson(response, 201, created); } catch (error) { sendJson(response, 400, { status: "error", message: error.message }); } return; } if (request.method === "POST" && url.pathname === "/api/catalog/games") { try { const body = await readJsonBody(request); const created = await createGame(body); sendJson(response, 201, created); } catch (error) { sendJson(response, 400, { status: "error", message: error.message }); } return; } if (request.method === "POST" && url.pathname === "/api/catalog/import") { try { const body = await readJsonBody(request); const result = await importCatalog(body); sendJson(response, 200, { status: "ok", ...result }); } catch (error) { sendJson(response, 400, { status: "error", message: error.message }); } return; } const gameIdMatch = url.pathname.match(/^\/api\/catalog\/games\/([0-9a-fA-F-]+)$/); if (request.method === "PUT" && gameIdMatch) { try { const body = await readJsonBody(request); const updated = await updateGame(gameIdMatch[1], body); sendJson(response, 200, updated); } catch (error) { const statusCode = error.message === "game not found" ? 404 : 400; sendJson(response, statusCode, { status: "error", message: error.message }); } return; } if (request.method === "DELETE" && gameIdMatch) { try { const deleted = await deleteGame(gameIdMatch[1]); if (!deleted) { sendJson(response, 404, { status: "error", message: "game not found" }); return; } sendJson(response, 200, { status: "ok" }); } catch (error) { sendJson(response, 400, { status: "error", message: error.message }); } return; } const gameToggleMatch = url.pathname.match(/^\/api\/catalog\/games\/([0-9a-fA-F-]+)\/toggle-loan$/); if (request.method === "POST" && gameToggleMatch) { try { const toggled = await toggleGameLoan(gameToggleMatch[1]); if (!toggled) { sendJson(response, 404, { status: "error", message: "game not found" }); return; } sendJson(response, 200, { status: "ok" }); } catch (error) { sendJson(response, 400, { status: "error", message: error.message }); } return; } sendJson(response, 404, { status: "not_found", message: "Route not found", }); } async function start() { await runMigrations(); const server = http.createServer((request, response) => { handleRequest(request, response).catch((error) => { sendJson(response, 500, { status: "error", message: error.message }); }); }); server.listen(port, "0.0.0.0", () => { console.log(`${serviceName} listening on port ${port}`); }); } start().catch((error) => { console.error("Failed to start API:", error); process.exit(1); });