diff --git a/README.md b/README.md index e7fa430..049d209 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,12 @@ git pull - trigger `updated_at` sur `games` - endpoints de lecture pour validation: `summary` et `tree` - Etape 3: frontend lit l'API (`/api/catalog/full`) avec fallback `localStorage` si API vide ou indisponible +- Etape 4: ecriture active en base via API + - POST `/api/catalog/consoles` + - POST `/api/catalog/games` + - PUT `/api/catalog/games/:id` + - DELETE `/api/catalog/games/:id` + - POST `/api/catalog/games/:id/toggle-loan` ## Licence diff --git a/api/server.js b/api/server.js index b22d62c..4c37bbe 100644 --- a/api/server.js +++ b/api/server.js @@ -16,6 +16,68 @@ function sendJson(response, statusCode, payload) { 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(` @@ -176,6 +238,125 @@ async function getCatalogFull() { 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 handleRequest(request, response) { const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`); @@ -229,6 +410,70 @@ async function handleRequest(request, response) { 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; + } + + 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", diff --git a/app.js b/app.js index 95a0008..4b7cc3c 100644 --- a/app.js +++ b/app.js @@ -12,6 +12,7 @@ const initialState = { const state = loadState(); let dataMode = "local"; +let apiReachable = false; const platformForm = document.getElementById("platformForm"); const gameForm = document.getElementById("gameForm"); @@ -34,7 +35,7 @@ const gamesList = document.getElementById("gamesList"); const gameCardTemplate = document.getElementById("gameCardTemplate"); let editingGameId = null; -platformForm.addEventListener("submit", (event) => { +platformForm.addEventListener("submit", async (event) => { event.preventDefault(); const brand = brandInput.value.trim().toUpperCase(); @@ -44,6 +45,22 @@ platformForm.addEventListener("submit", (event) => { return; } + if (apiReachable) { + try { + await apiRequest("/api/catalog/consoles", { + method: "POST", + body: { brand, consoleName }, + }); + + platformForm.reset(); + await refreshFromApi(brand, consoleName); + return; + } catch (error) { + console.error(error); + alert("Impossible d'ajouter cette section via l'API."); + } + } + state.brands[brand] = state.brands[brand] || []; if (!state.brands[brand].includes(consoleName)) { state.brands[brand].push(consoleName); @@ -58,7 +75,7 @@ platformForm.addEventListener("submit", (event) => { render(); }); -gameForm.addEventListener("submit", (event) => { +gameForm.addEventListener("submit", async (event) => { event.preventDefault(); const title = titleInput.value.trim(); @@ -66,6 +83,40 @@ gameForm.addEventListener("submit", (event) => { return; } + if (apiReachable) { + const payload = { + brand: state.selectedBrand, + consoleName: state.selectedConsole, + title, + genre: genreInput.value.trim(), + publisher: publisherInput.value.trim(), + year: yearInput.value ? Number(yearInput.value) : null, + value: valueInput.value ? Number(valueInput.value) : null, + loanedTo: loanedToInput.value.trim(), + }; + + try { + if (editingGameId) { + await apiRequest(`/api/catalog/games/${editingGameId}`, { + method: "PUT", + body: payload, + }); + } else { + await apiRequest("/api/catalog/games", { + method: "POST", + body: payload, + }); + } + + resetEditMode(); + await refreshFromApi(state.selectedBrand, state.selectedConsole); + return; + } catch (error) { + console.error(error); + alert("Impossible d'enregistrer ce jeu via l'API."); + } + } + state.gamesByConsole[state.selectedConsole] = state.gamesByConsole[state.selectedConsole] || []; if (editingGameId) { @@ -143,7 +194,7 @@ consoleTabs.addEventListener("click", (event) => { render(); }); -gamesList.addEventListener("click", (event) => { +gamesList.addEventListener("click", async (event) => { if (!(event.target instanceof Element)) { return; } @@ -164,6 +215,32 @@ gamesList.addEventListener("click", (event) => { return; } + if (action === "edit") { + startEditMode(games[idx]); + return; + } + + if (apiReachable) { + try { + if (action === "delete") { + await apiRequest(`/api/catalog/games/${id}`, { method: "DELETE" }); + if (editingGameId === id) { + resetEditMode(); + } + } + + if (action === "toggle-loan") { + await apiRequest(`/api/catalog/games/${id}/toggle-loan`, { method: "POST" }); + } + + await refreshFromApi(state.selectedBrand, state.selectedConsole); + return; + } catch (error) { + console.error(error); + alert("Action impossible via l'API."); + } + } + if (action === "delete") { games.splice(idx, 1); if (editingGameId === id) { @@ -175,11 +252,6 @@ gamesList.addEventListener("click", (event) => { games[idx].loanedTo = games[idx].loanedTo ? "" : "A renseigner"; } - if (action === "edit") { - startEditMode(games[idx]); - return; - } - persist(); render(); }); @@ -201,12 +273,17 @@ function renderDataMode() { } if (dataMode === "api") { - dataModeInfo.textContent = "Source: API (lecture). Ecriture DB prevue a l'etape 4."; + dataModeInfo.textContent = "Source: API (lecture/ecriture active sur la base de donnees)."; + return; + } + + if (dataMode === "api-empty") { + dataModeInfo.textContent = "Source: API (base vide). Ajoute une section pour demarrer."; return; } if (dataMode === "local-fallback") { - dataModeInfo.textContent = "Source: localStorage (fallback). API indisponible ou vide."; + dataModeInfo.textContent = "Source: localStorage (fallback). API indisponible."; return; } @@ -332,16 +409,16 @@ function loadState() { } function normalizeState() { - state.brands = state.brands || structuredClone(initialState.brands); + state.brands = state.brands || {}; state.gamesByConsole = state.gamesByConsole || {}; const brands = Object.keys(state.brands); - if (!brands.length) { + if (!brands.length && !apiReachable) { state.brands = structuredClone(initialState.brands); } if (!state.selectedBrand || !state.brands[state.selectedBrand]) { - state.selectedBrand = Object.keys(state.brands)[0]; + state.selectedBrand = Object.keys(state.brands)[0] || ""; } const consoles = state.brands[state.selectedBrand] || []; @@ -354,6 +431,46 @@ function persist() { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } +async function apiRequest(path, options = {}) { + const requestOptions = { + method: options.method || "GET", + headers: {}, + }; + + if (options.body !== undefined) { + requestOptions.headers["Content-Type"] = "application/json"; + requestOptions.body = JSON.stringify(options.body); + } + + const response = await fetch(path, requestOptions); + const rawText = await response.text(); + const payload = rawText ? JSON.parse(rawText) : {}; + + if (!response.ok) { + const message = payload && payload.message ? payload.message : `HTTP ${response.status}`; + throw new Error(message); + } + + return payload; +} + +function applyCatalogPayload(payload, preferredBrand, preferredConsole) { + state.brands = payload.brands || {}; + state.gamesByConsole = payload.gamesByConsole || {}; + + normalizeState(); + + if (preferredBrand && state.brands[preferredBrand]) { + state.selectedBrand = preferredBrand; + const consoles = state.brands[preferredBrand] || []; + if (preferredConsole && consoles.includes(preferredConsole)) { + state.selectedConsole = preferredConsole; + } else { + state.selectedConsole = consoles[0] || ""; + } + } +} + function payloadHasCatalogData(payload) { if (!payload || typeof payload !== "object") { return false; @@ -380,26 +497,20 @@ function payloadHasCatalogData(payload) { return consolesCount > 0 || gamesCount > 0; } +async function refreshFromApi(preferredBrand, preferredConsole) { + const payload = await apiRequest("/api/catalog/full"); + apiReachable = true; + dataMode = payloadHasCatalogData(payload) ? "api" : "api-empty"; + applyCatalogPayload(payload, preferredBrand, preferredConsole); + persist(); + render(); +} + async function hydrateFromApi() { try { - const response = await fetch("/api/catalog/full"); - if (!response.ok) { - throw new Error(`API error ${response.status}`); - } - - const payload = await response.json(); - if (!payloadHasCatalogData(payload)) { - dataMode = "local-fallback"; - return; - } - - state.brands = payload.brands || {}; - state.gamesByConsole = payload.gamesByConsole || {}; - state.selectedBrand = Object.keys(state.brands)[0] || ""; - state.selectedConsole = (state.brands[state.selectedBrand] || [])[0] || ""; - dataMode = "api"; - persist(); + await refreshFromApi(state.selectedBrand, state.selectedConsole); } catch { + apiReachable = false; dataMode = "local-fallback"; } }