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

982 lines
29 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,
game_version TEXT,
is_duplicate BOOLEAN NOT NULL DEFAULT FALSE,
release_year INTEGER CHECK (release_year IS NULL OR (release_year >= 1970 AND release_year <= 2100)),
purchase_price NUMERIC(10,2) CHECK (purchase_price IS NULL OR purchase_price >= 0),
estimated_value NUMERIC(10,2) CHECK (estimated_value IS NULL OR estimated_value >= 0),
condition_score NUMERIC(4,2),
loaned_to TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS game_version TEXT;");
await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS is_duplicate BOOLEAN NOT NULL DEFAULT FALSE;");
await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS purchase_price NUMERIC(10,2);");
await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS condition_score NUMERIC(4,2);");
await pool.query(`
CREATE TABLE IF NOT EXISTS backup_snapshots (
id BIGSERIAL PRIMARY KEY,
reason TEXT NOT NULL,
dump_payload JSONB NOT NULL,
created_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.game_version,
g.is_duplicate,
g.release_year,
g.purchase_price,
g.estimated_value,
g.condition_score,
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 || "",
version: row.game_version || "",
isDuplicate: Boolean(row.is_duplicate),
year: row.release_year || null,
purchasePrice: row.purchase_price != null ? Number(row.purchase_price) : null,
value: row.estimated_value != null ? Number(row.estimated_value) : null,
condition: row.condition_score != null ? Number(row.condition_score) : null,
loanedTo: row.loaned_to || "",
createdAt: row.created_at,
});
}
return { brands, gamesByConsole };
}
async function exportCatalogDumpWithClient(client) {
const brandsResult = await client.query(`
SELECT id::int AS id, name
FROM brands
ORDER BY name ASC;
`);
const consolesResult = await client.query(`
SELECT
c.id::int AS id,
b.name AS brand,
c.name
FROM consoles c
JOIN brands b ON b.id = c.brand_id
ORDER BY b.name ASC, c.name ASC;
`);
const gamesResult = await client.query(`
SELECT
g.id::text AS id,
b.name AS brand,
c.name AS console_name,
g.title,
g.genre,
g.publisher,
g.game_version,
g.is_duplicate,
g.release_year,
g.purchase_price,
g.estimated_value,
g.condition_score,
g.loaned_to,
g.created_at,
g.updated_at
FROM games g
JOIN consoles c ON c.id = g.console_id
JOIN brands b ON b.id = c.brand_id
ORDER BY g.created_at DESC;
`);
return {
version: "1.0",
exportedAt: new Date().toISOString(),
source: serviceName,
summary: {
brands: brandsResult.rows.length,
consoles: consolesResult.rows.length,
games: gamesResult.rows.length,
},
catalog: {
brands: brandsResult.rows.map((row) => ({
name: row.name,
})),
consoles: consolesResult.rows.map((row) => ({
brand: row.brand,
name: row.name,
})),
games: gamesResult.rows.map((row) => ({
id: row.id,
brand: row.brand,
consoleName: row.console_name,
title: row.title,
genre: row.genre || "",
publisher: row.publisher || "",
version: row.game_version || "",
isDuplicate: Boolean(row.is_duplicate),
year: row.release_year || null,
purchasePrice: row.purchase_price != null ? Number(row.purchase_price) : null,
value: row.estimated_value != null ? Number(row.estimated_value) : null,
condition: row.condition_score != null ? Number(row.condition_score) : null,
loanedTo: row.loaned_to || "",
createdAt: row.created_at,
updatedAt: row.updated_at,
})),
},
};
}
async function exportCatalogDump() {
const client = await pool.connect();
try {
return await exportCatalogDumpWithClient(client);
} finally {
client.release();
}
}
function validateBackupDump(dump) {
if (!dump || typeof dump !== "object") {
throw new Error("Invalid dump payload");
}
if (!dump.catalog || typeof dump.catalog !== "object") {
throw new Error("Invalid dump catalog");
}
const { brands, consoles, games } = dump.catalog;
if (!Array.isArray(brands) || !Array.isArray(consoles) || !Array.isArray(games)) {
throw new Error("Invalid dump arrays");
}
}
function normalizeDateOrNull(value) {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toISOString();
}
async function restoreCatalogDump(mode, dump) {
validateBackupDump(dump);
const restoreMode = mode === "replace" ? "replace" : "merge";
const client = await pool.connect();
let insertedConsoles = 0;
let insertedGames = 0;
let preRestoreSnapshotId = null;
try {
await client.query("BEGIN");
if (restoreMode === "replace") {
const snapshotDump = await exportCatalogDumpWithClient(client);
const snapshotResult = await client.query(
`
INSERT INTO backup_snapshots(reason, dump_payload)
VALUES ($1, $2::jsonb)
RETURNING id::int AS id;
`,
["pre-restore-replace", JSON.stringify(snapshotDump)],
);
preRestoreSnapshotId = snapshotResult.rows[0].id;
await client.query("DELETE FROM games;");
await client.query("DELETE FROM consoles;");
await client.query("DELETE FROM brands;");
}
for (const brandEntry of dump.catalog.brands) {
const brand = normalizeText(brandEntry && brandEntry.name).toUpperCase();
if (!brand) {
continue;
}
await client.query(
`
INSERT INTO brands(name)
VALUES ($1)
ON CONFLICT (name) DO NOTHING;
`,
[brand],
);
}
for (const consoleEntry of dump.catalog.consoles) {
const brand = normalizeText(consoleEntry && consoleEntry.brand).toUpperCase();
const consoleName = normalizeText(consoleEntry && consoleEntry.name);
if (!brand || !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;
`,
[brand, consoleName],
);
const ensured = await ensureConsole(client, brand, consoleName);
if (ensured && ensured.consoleId && !existingConsole.rowCount) {
insertedConsoles += 1;
}
}
for (const gameEntry of dump.catalog.games) {
const brand = normalizeText(gameEntry && gameEntry.brand).toUpperCase();
const consoleName = normalizeText(gameEntry && gameEntry.consoleName);
const title = normalizeText(gameEntry && gameEntry.title);
if (!brand || !consoleName || !title) {
continue;
}
const ensured = await ensureConsole(client, brand, consoleName);
const consoleId = ensured.consoleId;
const year = gameEntry && gameEntry.year != null && gameEntry.year !== "" ? Number(gameEntry.year) : null;
const version = normalizeText(gameEntry && gameEntry.version);
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)
AND COALESCE(LOWER(game_version), '') = COALESCE(LOWER($4), '')
LIMIT 1;
`,
[consoleId, title, year, version || null],
);
if (dedupeResult.rowCount) {
continue;
}
const genre = normalizeText(gameEntry && gameEntry.genre) || null;
const publisher = normalizeText(gameEntry && gameEntry.publisher) || null;
const loanedTo = normalizeText(gameEntry && gameEntry.loanedTo) || null;
const isDuplicate = Boolean(gameEntry && gameEntry.isDuplicate);
const purchasePrice =
gameEntry && gameEntry.purchasePrice != null && gameEntry.purchasePrice !== ""
? Number(gameEntry.purchasePrice)
: null;
const value = gameEntry && gameEntry.value != null && gameEntry.value !== "" ? Number(gameEntry.value) : null;
const condition =
gameEntry && gameEntry.condition != null && gameEntry.condition !== "" ? Number(gameEntry.condition) : null;
const createdAt = normalizeDateOrNull(gameEntry && gameEntry.createdAt);
await client.query(
`
INSERT INTO games(
console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price,
estimated_value, condition_score, loaned_to, created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, COALESCE($12::timestamptz, NOW()));
`,
[
consoleId,
title,
genre,
publisher,
version || null,
isDuplicate,
year,
purchasePrice,
value,
condition,
loanedTo,
createdAt,
],
);
insertedGames += 1;
}
await client.query("COMMIT");
return {
mode: restoreMode,
insertedConsoles,
insertedGames,
preRestoreSnapshotId,
};
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
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 version = normalizeText(payload.version) || null;
const isDuplicate = Boolean(payload.isDuplicate);
const loanedTo = normalizeText(payload.loanedTo) || null;
const year = payload.year != null && payload.year !== "" ? Number(payload.year) : null;
const purchasePrice =
payload.purchasePrice != null && payload.purchasePrice !== "" ? Number(payload.purchasePrice) : null;
const value = payload.value != null && payload.value !== "" ? Number(payload.value) : null;
const condition = payload.condition != null && payload.condition !== "" ? Number(payload.condition) : null;
const insertResult = await client.query(
`
INSERT INTO games(
console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price,
estimated_value, condition_score, loaned_to
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id::text AS id;
`,
[consoleData.consoleId, title, genre, publisher, version, isDuplicate, year, purchasePrice, value, condition, 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 version = normalizeText(payload.version) || null;
const isDuplicate = Boolean(payload.isDuplicate);
const loanedTo = normalizeText(payload.loanedTo) || null;
const year = payload.year != null && payload.year !== "" ? Number(payload.year) : null;
const purchasePrice =
payload.purchasePrice != null && payload.purchasePrice !== "" ? Number(payload.purchasePrice) : null;
const value = payload.value != null && payload.value !== "" ? Number(payload.value) : null;
const condition = payload.condition != null && payload.condition !== "" ? Number(payload.condition) : null;
const updateResult = await client.query(
`
UPDATE games
SET
console_id = $2,
title = $3,
genre = $4,
publisher = $5,
game_version = $6,
is_duplicate = $7,
release_year = $8,
purchase_price = $9,
estimated_value = $10,
condition_score = $11,
loaned_to = $12
WHERE id = $1::uuid
RETURNING id::text AS id;
`,
[
id,
consoleData.consoleId,
title,
genre,
publisher,
version,
isDuplicate,
year,
purchasePrice,
value,
condition,
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 version = normalizeText(game && game.version);
const isDuplicate = Boolean(game && game.isDuplicate);
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 purchasePrice =
game && game.purchasePrice != null && game.purchasePrice !== "" ? Number(game.purchasePrice) : null;
const value = game && game.value != null && game.value !== "" ? Number(game.value) : null;
const condition = game && game.condition != null && game.condition !== "" ? Number(game.condition) : 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)
AND COALESCE(LOWER(game_version), '') = COALESCE(LOWER($4), '')
LIMIT 1;
`,
[consoleId, title, year, version || null],
);
if (dedupeResult.rowCount) {
continue;
}
await client.query(
`
INSERT INTO games(
console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price,
estimated_value, condition_score, loaned_to
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
`,
[consoleId, title, genre, publisher, version || null, isDuplicate, year, purchasePrice, value, condition, 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;
}
if (request.method === "GET" && url.pathname === "/api/backup/export") {
try {
const dump = await exportCatalogDump();
sendJson(response, 200, dump);
} catch (error) {
sendJson(response, 500, { status: "error", message: error.message });
}
return;
}
if (request.method === "POST" && url.pathname === "/api/backup/restore") {
try {
const body = await readJsonBody(request);
const mode = body && body.mode ? body.mode : "merge";
const dump = body && body.dump ? body.dump : body;
const result = await restoreCatalogDump(mode, dump);
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);
});