Step 2 migration: add PostgreSQL schema and read endpoints

This commit is contained in:
Ponte
2026-02-11 14:57:36 +01:00
parent aae5471862
commit 89d9275e1a
6 changed files with 203 additions and 11 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
gitea_ed25519 gitea_ed25519
gitea_ed25519.pub gitea_ed25519.pub
.env .env
node_modules

View File

@@ -23,6 +23,7 @@ Centraliser ta collection dans une interface rapide a utiliser, evolutive, et fa
- Suppression d'un jeu - Suppression d'un jeu
- Statut de pret (marquer prete/rendu) - Statut de pret (marquer prete/rendu)
- Persistance locale via `localStorage` - Persistance locale via `localStorage`
- Backend avec migration automatique du schema PostgreSQL (etape 2)
## Stack technique ## Stack technique
@@ -84,6 +85,8 @@ docker compose up -d --build
### 3-bis) Verifier l'API (etape 1 migration) ### 3-bis) Verifier l'API (etape 1 migration)
- [http://localhost:7002/health](http://localhost:7002/health) - [http://localhost:7002/health](http://localhost:7002/health)
- [http://localhost:7002/api/catalog/summary](http://localhost:7002/api/catalog/summary)
- [http://localhost:7002/api/catalog/tree](http://localhost:7002/api/catalog/tree)
### 4) Arreter ### 4) Arreter
@@ -118,6 +121,14 @@ git pull
- Export/import (CSV/JSON) - Export/import (CSV/JSON)
- Sauvegarde distante (API/backend) - Sauvegarde distante (API/backend)
## Etat migration base de donnees
- Etape 1: architecture `app + api + db` en place
- Etape 2: schema SQL applique automatiquement au demarrage API
- tables: `brands`, `consoles`, `games`
- trigger `updated_at` sur `games`
- endpoints de lecture pour validation: `summary` et `tree`
## Licence ## Licence
Projet prive personnel. Projet prive personnel.

View File

@@ -1,6 +1,8 @@
FROM node:20-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
COPY api/package.json ./package.json
RUN npm install --omit=dev
COPY api/server.js ./server.js COPY api/server.js ./server.js
EXPOSE 3001 EXPOSE 3001

13
api/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "video-games-api",
"version": "0.1.0",
"private": true,
"description": "Minimal API for video game collection migration steps",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"pg": "^8.16.3"
}
}

View File

@@ -1,22 +1,167 @@
const http = require("http"); const http = require("http");
const { Pool } = require("pg");
const port = Number(process.env.API_INTERNAL_PORT || 3001); const port = Number(process.env.API_INTERNAL_PORT || 3001);
const serviceName = process.env.SERVICE_NAME || "video-games-api"; 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) { function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" }); response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
response.end(JSON.stringify(payload)); response.end(JSON.stringify(payload));
} }
const server = http.createServer((request, response) => { async function runMigrations() {
await pool.query("CREATE EXTENSION IF NOT EXISTS pgcrypto;");
await pool.query(`
CREATE TABLE IF NOT EXISTS brands (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS consoles (
id BIGSERIAL PRIMARY KEY,
brand_id BIGINT NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (brand_id, name)
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS games (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
console_id BIGINT NOT NULL REFERENCES consoles(id) ON DELETE CASCADE,
title TEXT NOT NULL,
genre TEXT,
publisher TEXT,
release_year INTEGER CHECK (release_year IS NULL OR (release_year >= 1970 AND release_year <= 2100)),
estimated_value NUMERIC(10,2) CHECK (estimated_value IS NULL OR estimated_value >= 0),
loaned_to TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
await pool.query(`
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await pool.query(`
DROP TRIGGER IF EXISTS trg_games_updated_at ON games;
CREATE TRIGGER trg_games_updated_at
BEFORE UPDATE ON games
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
`);
}
async function querySingleValue(sqlText) {
const result = await pool.query(sqlText);
return Number(result.rows[0].count || 0);
}
async function getCatalogSummary() {
const [brands, consoles, games] = await Promise.all([
querySingleValue("SELECT COUNT(*)::int AS count FROM brands;"),
querySingleValue("SELECT COUNT(*)::int AS count FROM consoles;"),
querySingleValue("SELECT COUNT(*)::int AS count FROM games;"),
]);
return { brands, consoles, games };
}
async function getCatalogTree() {
const result = await pool.query(`
SELECT
b.id::int AS brand_id,
b.name AS brand_name,
c.id::int AS console_id,
c.name AS console_name,
COUNT(g.id)::int AS games_count
FROM brands b
LEFT JOIN consoles c ON c.brand_id = b.id
LEFT JOIN games g ON g.console_id = c.id
GROUP BY b.id, b.name, c.id, c.name
ORDER BY b.name ASC, c.name ASC;
`);
const brandMap = new Map();
for (const row of result.rows) {
if (!brandMap.has(row.brand_id)) {
brandMap.set(row.brand_id, {
id: row.brand_id,
name: row.brand_name,
consoles: [],
});
}
if (row.console_id) {
brandMap.get(row.brand_id).consoles.push({
id: row.console_id,
name: row.console_name,
gamesCount: row.games_count,
});
}
}
return Array.from(brandMap.values());
}
async function handleRequest(request, response) {
const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`); const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`);
if (request.method === "GET" && url.pathname === "/health") { if (request.method === "GET" && url.pathname === "/health") {
sendJson(response, 200, { try {
status: "ok", await pool.query("SELECT 1;");
service: serviceName, sendJson(response, 200, {
timestamp: new Date().toISOString(), 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; return;
} }
@@ -24,9 +169,23 @@ const server = http.createServer((request, response) => {
status: "not_found", status: "not_found",
message: "Route not found", message: "Route not found",
}); });
}); }
server.listen(port, "0.0.0.0", () => { async function start() {
// Keep startup logs minimal and readable in docker compose logs. await runMigrations();
console.log(`${serviceName} listening on port ${port}`);
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);
}); });

View File

@@ -7,6 +7,11 @@ services:
- POSTGRES_DB=${VG_DB_NAME:-video_games} - POSTGRES_DB=${VG_DB_NAME:-video_games}
- POSTGRES_USER=${VG_DB_USER:-video_games_user} - POSTGRES_USER=${VG_DB_USER:-video_games_user}
- POSTGRES_PASSWORD=${VG_DB_PASSWORD:-change_me} - POSTGRES_PASSWORD=${VG_DB_PASSWORD:-change_me}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${VG_DB_USER:-video_games_user} -d ${VG_DB_NAME:-video_games}"]
interval: 5s
timeout: 3s
retries: 20
volumes: volumes:
- video_games_data:/var/lib/postgresql/data - video_games_data:/var/lib/postgresql/data
@@ -17,7 +22,8 @@ services:
container_name: video-games-api container_name: video-games-api
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- video-games-db video-games-db:
condition: service_healthy
environment: environment:
- SERVICE_NAME=video-games-api - SERVICE_NAME=video-games-api
- API_INTERNAL_PORT=3001 - API_INTERNAL_PORT=3001