1298 lines
37 KiB
JavaScript
1298 lines
37 KiB
JavaScript
const http = require("http");
|
|
const crypto = require("crypto");
|
|
const { Pool } = require("pg");
|
|
const { google } = require("googleapis");
|
|
|
|
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 publicBaseUrl = process.env.PUBLIC_BASE_URL || "http://localhost:7001";
|
|
const googleClientId = process.env.GOOGLE_CLIENT_ID || "";
|
|
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET || "";
|
|
const googleRedirectUri = process.env.GOOGLE_REDIRECT_URI || "";
|
|
const googleDriveFolderName = process.env.GOOGLE_DRIVE_FOLDER_NAME || "BeuzGamesBackups";
|
|
|
|
const pool = new Pool({
|
|
connectionString: databaseUrl,
|
|
});
|
|
const oauthStateCache = new Map();
|
|
|
|
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 TABLE IF NOT EXISTS google_drive_auth (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
provider TEXT NOT NULL UNIQUE,
|
|
refresh_token TEXT,
|
|
access_token TEXT,
|
|
expiry_date BIGINT,
|
|
scope TEXT,
|
|
email TEXT,
|
|
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.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();
|
|
}
|
|
}
|
|
|
|
function isGoogleConfigured() {
|
|
return Boolean(googleClientId && googleClientSecret && googleRedirectUri);
|
|
}
|
|
|
|
function buildGoogleOAuthClient() {
|
|
if (!isGoogleConfigured()) {
|
|
throw new Error("Google OAuth is not configured");
|
|
}
|
|
return new google.auth.OAuth2(googleClientId, googleClientSecret, googleRedirectUri);
|
|
}
|
|
|
|
async function getGoogleAuthRow() {
|
|
const result = await pool.query(
|
|
"SELECT * FROM google_drive_auth WHERE provider = 'google' ORDER BY id ASC LIMIT 1;",
|
|
);
|
|
return result.rows[0] || null;
|
|
}
|
|
|
|
async function upsertGoogleAuthRow(data) {
|
|
await pool.query(
|
|
`
|
|
INSERT INTO google_drive_auth(provider, refresh_token, access_token, expiry_date, scope, email, updated_at)
|
|
VALUES ('google', $1, $2, $3, $4, $5, NOW())
|
|
ON CONFLICT (provider)
|
|
DO UPDATE SET
|
|
refresh_token = COALESCE(EXCLUDED.refresh_token, google_drive_auth.refresh_token),
|
|
access_token = EXCLUDED.access_token,
|
|
expiry_date = EXCLUDED.expiry_date,
|
|
scope = EXCLUDED.scope,
|
|
email = EXCLUDED.email,
|
|
updated_at = NOW();
|
|
`,
|
|
[
|
|
data.refreshToken || null,
|
|
data.accessToken || null,
|
|
data.expiryDate || null,
|
|
data.scope || null,
|
|
data.email || null,
|
|
],
|
|
);
|
|
}
|
|
|
|
function getScopes() {
|
|
return [
|
|
"https://www.googleapis.com/auth/drive.file",
|
|
"https://www.googleapis.com/auth/userinfo.email",
|
|
"openid",
|
|
];
|
|
}
|
|
|
|
async function buildGoogleAuthUrl() {
|
|
const oauth2Client = buildGoogleOAuthClient();
|
|
const state = crypto.randomBytes(24).toString("hex");
|
|
oauthStateCache.set(state, Date.now() + 10 * 60 * 1000);
|
|
|
|
const url = oauth2Client.generateAuthUrl({
|
|
access_type: "offline",
|
|
prompt: "consent",
|
|
scope: getScopes(),
|
|
state,
|
|
});
|
|
|
|
return url;
|
|
}
|
|
|
|
function cleanupExpiredStates() {
|
|
const now = Date.now();
|
|
for (const [state, expiresAt] of oauthStateCache.entries()) {
|
|
if (expiresAt < now) {
|
|
oauthStateCache.delete(state);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleGoogleCallback(urlObj) {
|
|
cleanupExpiredStates();
|
|
|
|
const code = urlObj.searchParams.get("code");
|
|
const state = urlObj.searchParams.get("state");
|
|
if (!code || !state || !oauthStateCache.has(state)) {
|
|
throw new Error("Invalid OAuth callback state");
|
|
}
|
|
oauthStateCache.delete(state);
|
|
|
|
const oauth2Client = buildGoogleOAuthClient();
|
|
const { tokens } = await oauth2Client.getToken(code);
|
|
oauth2Client.setCredentials(tokens);
|
|
|
|
const oauth2 = google.oauth2({ auth: oauth2Client, version: "v2" });
|
|
const me = await oauth2.userinfo.get();
|
|
const email = (me.data && me.data.email) || "";
|
|
|
|
await upsertGoogleAuthRow({
|
|
refreshToken: tokens.refresh_token || null,
|
|
accessToken: tokens.access_token || null,
|
|
expiryDate: tokens.expiry_date || null,
|
|
scope: tokens.scope || "",
|
|
email,
|
|
});
|
|
}
|
|
|
|
async function getGoogleStatus() {
|
|
if (!isGoogleConfigured()) {
|
|
return { configured: false, connected: false };
|
|
}
|
|
|
|
const row = await getGoogleAuthRow();
|
|
const connected = Boolean(row && row.refresh_token);
|
|
return {
|
|
configured: true,
|
|
connected,
|
|
email: connected ? row.email || "" : "",
|
|
};
|
|
}
|
|
|
|
async function getGoogleDriveClient() {
|
|
const status = await getGoogleStatus();
|
|
if (!status.configured) {
|
|
throw new Error("Google OAuth is not configured");
|
|
}
|
|
if (!status.connected) {
|
|
throw new Error("Google account is not connected");
|
|
}
|
|
|
|
const row = await getGoogleAuthRow();
|
|
const oauth2Client = buildGoogleOAuthClient();
|
|
oauth2Client.setCredentials({
|
|
refresh_token: row.refresh_token,
|
|
access_token: row.access_token || undefined,
|
|
expiry_date: row.expiry_date || undefined,
|
|
});
|
|
|
|
return google.drive({ version: "v3", auth: oauth2Client });
|
|
}
|
|
|
|
async function ensureDriveFolder(drive) {
|
|
const query = [
|
|
"mimeType='application/vnd.google-apps.folder'",
|
|
`name='${googleDriveFolderName.replace(/'/g, "\\'")}'`,
|
|
"trashed=false",
|
|
].join(" and ");
|
|
|
|
const found = await drive.files.list({
|
|
q: query,
|
|
fields: "files(id,name)",
|
|
pageSize: 1,
|
|
});
|
|
|
|
if (found.data.files && found.data.files.length) {
|
|
return found.data.files[0].id;
|
|
}
|
|
|
|
const created = await drive.files.create({
|
|
requestBody: {
|
|
name: googleDriveFolderName,
|
|
mimeType: "application/vnd.google-apps.folder",
|
|
},
|
|
fields: "id,name",
|
|
});
|
|
|
|
return created.data.id;
|
|
}
|
|
|
|
async function uploadBackupToGoogleDrive() {
|
|
const drive = await getGoogleDriveClient();
|
|
const dump = await exportCatalogDump();
|
|
const folderId = await ensureDriveFolder(drive);
|
|
const fileName = `video-games-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
|
|
|
const created = await drive.files.create({
|
|
requestBody: {
|
|
name: fileName,
|
|
parents: [folderId],
|
|
mimeType: "application/json",
|
|
},
|
|
media: {
|
|
mimeType: "application/json",
|
|
body: JSON.stringify(dump, null, 2),
|
|
},
|
|
fields: "id,name,createdTime",
|
|
});
|
|
|
|
return {
|
|
fileId: created.data.id,
|
|
fileName: created.data.name,
|
|
createdTime: created.data.createdTime,
|
|
};
|
|
}
|
|
|
|
async function listGoogleBackups() {
|
|
const drive = await getGoogleDriveClient();
|
|
const folderId = await ensureDriveFolder(drive);
|
|
const query = [
|
|
`'${folderId}' in parents`,
|
|
"mimeType='application/json'",
|
|
"trashed=false",
|
|
].join(" and ");
|
|
|
|
const found = await drive.files.list({
|
|
q: query,
|
|
pageSize: 50,
|
|
orderBy: "createdTime desc",
|
|
fields: "files(id,name,createdTime,size)",
|
|
});
|
|
|
|
return found.data.files || [];
|
|
}
|
|
|
|
async function restoreFromGoogleBackup(fileId, mode) {
|
|
const drive = await getGoogleDriveClient();
|
|
|
|
let targetId = fileId || "";
|
|
if (!targetId) {
|
|
const files = await listGoogleBackups();
|
|
if (!files.length) {
|
|
throw new Error("No Google Drive backup found");
|
|
}
|
|
targetId = files[0].id;
|
|
}
|
|
|
|
const file = await drive.files.get(
|
|
{
|
|
fileId: targetId,
|
|
alt: "media",
|
|
},
|
|
{ responseType: "json" },
|
|
);
|
|
|
|
const dump = file.data;
|
|
const result = await restoreCatalogDump(mode || "merge", dump);
|
|
return { fileId: targetId, ...result };
|
|
}
|
|
|
|
async function handleRequest(request, response) {
|
|
const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`);
|
|
|
|
if (request.method === "GET" && url.pathname === "/auth/google/callback") {
|
|
try {
|
|
await handleGoogleCallback(url);
|
|
response.writeHead(302, { Location: `${publicBaseUrl}/?google=connected` });
|
|
response.end();
|
|
} catch (error) {
|
|
response.writeHead(302, { Location: `${publicBaseUrl}/?google=error` });
|
|
response.end();
|
|
}
|
|
return;
|
|
}
|
|
|
|
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 === "GET" && url.pathname === "/api/google/status") {
|
|
try {
|
|
const status = await getGoogleStatus();
|
|
sendJson(response, 200, status);
|
|
} catch (error) {
|
|
sendJson(response, 500, { status: "error", message: error.message });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname === "/api/google/connect-url") {
|
|
try {
|
|
const connectUrl = await buildGoogleAuthUrl();
|
|
sendJson(response, 200, { url: connectUrl });
|
|
} catch (error) {
|
|
sendJson(response, 400, { status: "error", message: error.message });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (request.method === "POST" && url.pathname === "/api/google/backup/upload") {
|
|
try {
|
|
const result = await uploadBackupToGoogleDrive();
|
|
sendJson(response, 200, { status: "ok", ...result });
|
|
} catch (error) {
|
|
sendJson(response, 400, { status: "error", message: error.message });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && url.pathname === "/api/google/backups") {
|
|
try {
|
|
const files = await listGoogleBackups();
|
|
sendJson(response, 200, { files });
|
|
} catch (error) {
|
|
sendJson(response, 400, { status: "error", message: error.message });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (request.method === "POST" && url.pathname === "/api/google/backup/restore") {
|
|
try {
|
|
const body = await readJsonBody(request);
|
|
const result = await restoreFromGoogleBackup(body.fileId || "", body.mode || "merge");
|
|
sendJson(response, 200, { status: "ok", ...result });
|
|
} catch (error) {
|
|
sendJson(response, 400, { 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);
|
|
});
|