501 lines
14 KiB
JavaScript
501 lines
14 KiB
JavaScript
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 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;
|
|
}
|
|
|
|
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);
|
|
});
|