Step 6 migration: add JSON backup and restore flows
This commit is contained in:
@@ -140,6 +140,10 @@ git pull
|
|||||||
- POST `/api/catalog/import`
|
- POST `/api/catalog/import`
|
||||||
- bouton UI `Migrer localStorage vers DB`
|
- bouton UI `Migrer localStorage vers DB`
|
||||||
- deduplication: `console + titre + annee`
|
- deduplication: `console + titre + annee`
|
||||||
|
- Etape 6: backup/restauration locale JSON
|
||||||
|
- GET `/api/backup/export`
|
||||||
|
- POST `/api/backup/restore` (modes `merge` ou `replace`)
|
||||||
|
- snapshot auto en base avant restore `replace` (`backup_snapshots`)
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
267
api/server.js
267
api/server.js
@@ -113,6 +113,15 @@ async function runMigrations() {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
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(`
|
await pool.query(`
|
||||||
CREATE OR REPLACE FUNCTION set_updated_at()
|
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
@@ -238,6 +247,241 @@ async function getCatalogFull() {
|
|||||||
return { brands, gamesByConsole };
|
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.release_year,
|
||||||
|
g.estimated_value,
|
||||||
|
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 || "",
|
||||||
|
year: row.release_year || null,
|
||||||
|
value: row.estimated_value != null ? Number(row.estimated_value) : 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 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)
|
||||||
|
LIMIT 1;
|
||||||
|
`,
|
||||||
|
[consoleId, title, year],
|
||||||
|
);
|
||||||
|
|
||||||
|
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 value = gameEntry && gameEntry.value != null && gameEntry.value !== "" ? Number(gameEntry.value) : null;
|
||||||
|
const createdAt = normalizeDateOrNull(gameEntry && gameEntry.createdAt);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO games(
|
||||||
|
console_id, title, genre, publisher, release_year, estimated_value, loaned_to, created_at
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8::timestamptz, NOW()));
|
||||||
|
`,
|
||||||
|
[consoleId, title, genre, publisher, year, value, 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) {
|
async function createConsole(brand, consoleName) {
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
@@ -564,6 +808,29 @@ async function handleRequest(request, response) {
|
|||||||
return;
|
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-]+)$/);
|
const gameIdMatch = url.pathname.match(/^\/api\/catalog\/games\/([0-9a-fA-F-]+)$/);
|
||||||
if (request.method === "PUT" && gameIdMatch) {
|
if (request.method === "PUT" && gameIdMatch) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
92
app.js
92
app.js
@@ -33,9 +33,15 @@ const consoleTabs = document.getElementById("consoleTabs");
|
|||||||
const gameSectionTitle = document.getElementById("gameSectionTitle");
|
const gameSectionTitle = document.getElementById("gameSectionTitle");
|
||||||
const dataModeInfo = document.getElementById("dataModeInfo");
|
const dataModeInfo = document.getElementById("dataModeInfo");
|
||||||
const migrateBtn = document.getElementById("migrateBtn");
|
const migrateBtn = document.getElementById("migrateBtn");
|
||||||
|
const backupControls = document.getElementById("backupControls");
|
||||||
|
const backupBtn = document.getElementById("backupBtn");
|
||||||
|
const restoreMergeBtn = document.getElementById("restoreMergeBtn");
|
||||||
|
const restoreReplaceBtn = document.getElementById("restoreReplaceBtn");
|
||||||
|
const restoreFileInput = document.getElementById("restoreFileInput");
|
||||||
const gamesList = document.getElementById("gamesList");
|
const gamesList = document.getElementById("gamesList");
|
||||||
const gameCardTemplate = document.getElementById("gameCardTemplate");
|
const gameCardTemplate = document.getElementById("gameCardTemplate");
|
||||||
let editingGameId = null;
|
let editingGameId = null;
|
||||||
|
let pendingRestoreMode = "merge";
|
||||||
|
|
||||||
platformForm.addEventListener("submit", async (event) => {
|
platformForm.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -292,6 +298,86 @@ migrateBtn.addEventListener("click", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
backupBtn.addEventListener("click", async () => {
|
||||||
|
if (!apiReachable) {
|
||||||
|
alert("API indisponible. Sauvegarde JSON impossible.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dump = await apiRequest("/api/backup/export");
|
||||||
|
const blob = new Blob([JSON.stringify(dump, null, 2)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `video-games-backup-${stamp}.json`;
|
||||||
|
document.body.append(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert("Echec de la sauvegarde JSON.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
restoreMergeBtn.addEventListener("click", () => {
|
||||||
|
pendingRestoreMode = "merge";
|
||||||
|
restoreFileInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
restoreReplaceBtn.addEventListener("click", () => {
|
||||||
|
pendingRestoreMode = "replace";
|
||||||
|
restoreFileInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
restoreFileInput.addEventListener("change", async (event) => {
|
||||||
|
const input = event.target;
|
||||||
|
const file = input.files && input.files[0] ? input.files[0] : null;
|
||||||
|
input.value = "";
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiReachable) {
|
||||||
|
alert("API indisponible. Restauration impossible.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileText = await file.text();
|
||||||
|
const dump = JSON.parse(fileText);
|
||||||
|
|
||||||
|
if (pendingRestoreMode === "replace") {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
"Mode remplacement: la base actuelle sera remplacee. Une sauvegarde pre-restore sera creee. Continuer ?",
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiRequest("/api/backup/restore", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
mode: pendingRestoreMode,
|
||||||
|
dump,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingLocalImport = null;
|
||||||
|
await refreshFromApi(state.selectedBrand, state.selectedConsole);
|
||||||
|
alert(
|
||||||
|
`Restauration terminee (${result.mode}): ${result.insertedGames || 0} jeu(x), ${result.insertedConsoles || 0} console(s).`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert("Echec de la restauration JSON.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
renderDataMode();
|
renderDataMode();
|
||||||
renderBrandTabs();
|
renderBrandTabs();
|
||||||
@@ -322,6 +408,12 @@ function renderDataMode() {
|
|||||||
} else {
|
} else {
|
||||||
migrateBtn.classList.add("hidden");
|
migrateBtn.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (apiReachable) {
|
||||||
|
backupControls.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
backupControls.classList.add("hidden");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBrandTabs() {
|
function renderBrandTabs() {
|
||||||
|
|||||||
@@ -53,6 +53,12 @@
|
|||||||
<button id="migrateBtn" type="button" class="btn-secondary hidden">
|
<button id="migrateBtn" type="button" class="btn-secondary hidden">
|
||||||
Migrer localStorage vers DB
|
Migrer localStorage vers DB
|
||||||
</button>
|
</button>
|
||||||
|
<div id="backupControls" class="backup-controls hidden">
|
||||||
|
<button id="backupBtn" type="button" class="btn-secondary">Sauvegarder JSON</button>
|
||||||
|
<button id="restoreMergeBtn" type="button" class="btn-secondary">Restaurer (fusion)</button>
|
||||||
|
<button id="restoreReplaceBtn" type="button" class="btn-secondary">Restaurer (remplacement)</button>
|
||||||
|
<input id="restoreFileInput" type="file" accept="application/json" class="hidden" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="gameForm" class="grid-form game-form">
|
<form id="gameForm" class="grid-form game-form">
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ h1 {
|
|||||||
margin-bottom: 0.9rem;
|
margin-bottom: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-form {
|
.grid-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user