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); });