Files
Beuz-Video-Game/api/server.js

256 lines
6.8 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 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 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;
}
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);
});