From: Valentin Hulin Date: Fri, 27 Feb 2026 17:22:07 +0000 (+0100) Subject: Mise à jour price demande de christophe X-Git-Url: https://git.digitality.be/?a=commitdiff_plain;h=302b09d6cf0ef64d43db29602ed20197ad2d4198;p=pdw25-26 Mise à jour price demande de christophe --- diff --git a/Wallette/server/modules/price/.env.example b/Wallette/server/modules/price/.env.example new file mode 100644 index 0000000..629fb25 --- /dev/null +++ b/Wallette/server/modules/price/.env.example @@ -0,0 +1,7 @@ +# Configuration DB (à placer dans l'environnement de ton app) +DB_HOST=localhost +DB_USER=root +DB_PASSWORD= +DB_NAME=crypto +DB_PORT=3306 +DB_CONNECTION_LIMIT=10 diff --git a/Wallette/server/modules/price/app.js b/Wallette/server/modules/price/app.js deleted file mode 100644 index 4bb257a..0000000 --- a/Wallette/server/modules/price/app.js +++ /dev/null @@ -1 +0,0 @@ -import "./server.js"; diff --git a/Wallette/server/modules/price/db.js b/Wallette/server/modules/price/db.js index ee443a3..73023b7 100644 --- a/Wallette/server/modules/price/db.js +++ b/Wallette/server/modules/price/db.js @@ -1,9 +1,50 @@ import mysql from "mysql2/promise"; -export const db = mysql.createPool({ - host: "localhost", - user: "root", - password: "vava", - database: "crypto", - port: 3306 -}); +function readEnv(name, { required = true, fallback = undefined } = {}) { + const v = process.env[name]; + if (v == null || v === "") { + if (required) throw new Error(`Missing environment variable: ${name}`); + return fallback; + } + return v; +} + +/** + * Crée un pool MySQL à partir des variables d'environnement. + * + * Variables attendues: + * - DB_HOST (ex: localhost) + * - DB_USER (ex: root) + * - DB_PASSWORD (peut être vide) + * - DB_NAME (ex: crypto) + * - DB_PORT (optionnel, défaut 3306) + * - DB_CONNECTION_LIMIT (optionnel, défaut 10) + */ +export function createDbPool(overrides = {}) { + const host = readEnv("DB_HOST"); + const user = readEnv("DB_USER"); + const password = readEnv("DB_PASSWORD", { required: false, fallback: "" }); + const database = readEnv("DB_NAME"); + const port = Number(readEnv("DB_PORT", { required: false, fallback: "3306" })); + const connectionLimit = Number( + readEnv("DB_CONNECTION_LIMIT", { required: false, fallback: "10" }) + ); + + if (!Number.isFinite(port)) throw new Error("DB_PORT must be a number"); + if (!Number.isFinite(connectionLimit)) throw new Error("DB_CONNECTION_LIMIT must be a number"); + + return mysql.createPool({ + host, + user, + password, + database, + port, + connectionLimit, + // mysql2: recommandé pour éviter les erreurs de timezone (optionnel) + // timezone: 'Z', + ...overrides + }); +} + +// Export compat: les repositories importent `db`. +export const db = createDbPool(); diff --git a/Wallette/server/modules/price/index.js b/Wallette/server/modules/price/index.js new file mode 100644 index 0000000..c1e0735 --- /dev/null +++ b/Wallette/server/modules/price/index.js @@ -0,0 +1,4 @@ +// Point d'entrée du module: on exporte uniquement un Router Express. +// À monter côté app principale avec: app.use('/api', priceApiRouter) + +export { default as priceApiRouter } from "./routes/api.router.js"; diff --git a/Wallette/server/modules/price/routes/api.router.js b/Wallette/server/modules/price/routes/api.router.js new file mode 100644 index 0000000..e0e16ae --- /dev/null +++ b/Wallette/server/modules/price/routes/api.router.js @@ -0,0 +1,145 @@ +// price/routes/api.router.js +import { Router } from "express"; + +import { formatProviderError, getPriceWithFallback } from "../services/price.service.js"; +import { getActivePairIdByCode } from "../repositories/pair.repository.js"; +import { insertPricePoint, getCurrentPrice, getPriceHistory } from "../repositories/price.repository.js"; + +const router = Router(); + +function ok(res, data) { + return res.json({ ok: true, data }); +} + +function fail(res, status, code, message, details = null) { + return res.status(status).json({ ok: false, error: { code, message, details } }); +} + +function normalizePairCode(value) { + return String(value || "").trim().toUpperCase(); +} + +/** + * Convertit: + * - BTC/EUR, BTC_EUR, BTC-EUR, "BTC EUR" + * en { base:"BTC", quote:"EUR", dbCode:"BTC_EUR" } + */ +function parsePairInput(pairInput) { + const raw = normalizePairCode(pairInput); + const dbCode = raw.replace(/\s+/g, "").replace(/[-/]/g, "_"); + const parts = dbCode.split("_"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + const err = new Error("PAIR_INVALID"); + err.code = "PAIR_INVALID"; + throw err; + } + return { base: parts[0], quote: parts[1], dbCode }; +} + +// NOTE: +// - Ce Router est prévu pour être monté sur `/api` (ex: app.use('/api', router)). +// - Il expose UNIQUEMENT les endpoints du contrat: +// GET /api/price/current +// GET /api/price/history +// POST /api/price/refresh (optionnel) + +/** + * Prix courant + * GET /api/price/current?pair=BTC/EUR + */ +router.get("/price/current", async (req, res) => { + const pair = normalizePairCode(req.query.pair); + + if (!pair) return fail(res, 400, "PAIR_REQUIRED", "Parametre 'pair' obligatoire (ex: BTC/EUR)"); + + let pair_id; + try { + pair_id = await getActivePairIdByCode(pair); + } catch (err) { + if (err.code === "PAIR_NOT_FOUND") return fail(res, 404, "PAIR_NOT_FOUND", `Paire inconnue: ${pair}`); + return fail(res, 400, "PAIR_INVALID", `Paire invalide: ${pair}`); + } + + try { + const row = await getCurrentPrice(pair_id); + if (!row) return fail(res, 404, "NO_PRICE", `Aucun prix en base pour ${pair}`); + + return ok(res, { + pair, + pair_id, + source: row.source, + timestamp_ms: row.timestamp_ms, + current_price: row.current_price, + open_price: row.open_price, + high_price: row.high_price, + low_price: row.low_price, + close_price: row.close_price, + volume_24h: row.volume_24h ?? null + }); + } catch (err) { + return fail(res, 500, "DB_ERROR", "Erreur lecture prix courant", err.message); + } +}); + +/** + * Historique + * GET /api/price/history?pair=BTC/EUR&limit=200 + */ +router.get("/price/history", async (req, res) => { + const pair = normalizePairCode(req.query.pair); + const limit = req.query.limit; + + if (!pair) return fail(res, 400, "PAIR_REQUIRED", "Parametre 'pair' obligatoire (ex: BTC/EUR)"); + + let pair_id; + try { + pair_id = await getActivePairIdByCode(pair); + } catch (err) { + if (err.code === "PAIR_NOT_FOUND") return fail(res, 404, "PAIR_NOT_FOUND", `Paire inconnue: ${pair}`); + return fail(res, 400, "PAIR_INVALID", `Paire invalide: ${pair}`); + } + + try { + const points = await getPriceHistory(pair_id, limit); + return ok(res, { pair, pair_id, points }); + } catch (err) { + return fail(res, 500, "DB_ERROR", "Erreur lecture historique", err.message); + } +}); + +/** + * Refresh manuel + * POST /api/price/refresh?pair=BTC/EUR + */ +router.post("/price/refresh", async (req, res) => { + const pair = normalizePairCode(req.query.pair); + if (!pair) return fail(res, 400, "PAIR_REQUIRED", "Parametre 'pair' obligatoire (ex: BTC/EUR)"); + + try { + const { base, quote } = parsePairInput(pair); + const pair_id = await getActivePairIdByCode(pair); + + const data = await getPriceWithFallback("BINANCE", { base, quote }); + + await insertPricePoint({ + pair_id, + timestamp_ms: data.timestamp_ms, + current_price: data.price, + source: data.source + }); + + return ok(res, { + pair, + pair_id, + source: data.source, + timestamp_ms: data.timestamp_ms, + current_price: data.price, + used_fallback: Boolean(data.used_fallback) + }); + } catch (err) { + if (err.code === "PAIR_NOT_FOUND") return fail(res, 404, "PAIR_NOT_FOUND", `Paire inconnue: ${pair}`); + return fail(res, 503, "PROVIDER_ERROR", `Refresh impossible pour ${pair}`, formatProviderError(err)); + } +}); + +export default router; \ No newline at end of file diff --git a/Wallette/server/modules/price/server.js b/Wallette/server/modules/price/server.js deleted file mode 100644 index f652eec..0000000 --- a/Wallette/server/modules/price/server.js +++ /dev/null @@ -1,286 +0,0 @@ -import express from "express"; -import { db } from "./db.js"; - -import { formatProviderError, getPriceWithFallback } from "./services/price.service.js"; -import { getActivePairIdByCode, getPairById, listActivePairs } from "./repositories/pair.repository.js"; -import { insertPricePoint, getCurrentPrice, getPriceHistory } from "./repositories/price.repository.js"; - -const app = express(); -const PORT = 3000; - -// On fetch souvent pour construire OHLC (bougie 5 min) -const SAMPLE_INTERVAL_MS = 10 * 1000; // 10s - -// Toutes les paires actives (chargées au démarrage) -let PAIR_IDS_TO_SAMPLE = []; - -app.use(express.json()); -app.use(express.static("public")); - -function ok(res, data) { - return res.json({ ok: true, data }); -} - -function fail(res, status, code, message, details = null) { - return res.status(status).json({ - ok: false, - error: { code, message, details } - }); -} - -function normalizePairCode(value) { - return String(value || "").trim().toUpperCase(); -} - -/** - * Convertit: - * - BTC/EUR, BTC_EUR, BTC-EUR, "BTC EUR" - * en { base:"BTC", quote:"EUR", dbCode:"BTC_EUR" } - */ -function parsePairInput(pairInput) { - const raw = normalizePairCode(pairInput); - const dbCode = raw.replace(/\s+/g, "").replace(/[-/]/g, "_"); - const parts = dbCode.split("_"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - const err = new Error("PAIR_INVALID"); - err.code = "PAIR_INVALID"; - throw err; - } - return { base: parts[0], quote: parts[1], dbCode }; -} - -/** - * Reconstruit base/quote depuis pair_code DB (ex: BTC_EUR) - * -> { base:"BTC", quote:"EUR" } - */ -function parsePairCodeFromDb(pairCodeDb) { - const code = String(pairCodeDb || "").trim().toUpperCase(); - const parts = code.split("_"); - if (parts.length !== 2) throw new Error(`PAIR_CODE_DB_INVALID: ${code}`); - return { base: parts[0], quote: parts[1] }; -} - -/** - * Charge toutes les paires actives (is_active=1) depuis la DB - */ -async function loadAllActivePairIds() { - const pairs = await listActivePairs(); // doit être ORDER BY pair_id ASC dans le repo - PAIR_IDS_TO_SAMPLE = pairs.map((p) => p.pair_id); - console.log("PAIR_IDS_TO_SAMPLE (actives):", PAIR_IDS_TO_SAMPLE); -} - -/** - * ✅ Liste des paires (pour ton interface) - * GET /api/pairs - */ -app.get("/api/pairs", async (req, res) => { - try { - const pairs = await listActivePairs(); - return ok(res, { count: pairs.length, pairs }); - } catch (e) { - return fail(res, 500, "DB_ERROR", "Erreur lecture des paires", e.message); - } -}); - -/** - * DEBUG: Voir les paires en DB (tout) - * GET /api/debug/pairs - */ -app.get("/api/debug/pairs", async (req, res) => { - try { - const [rows] = await db.execute( - "SELECT pair_id, base_symbol, quote_symbol, pair_code, is_active FROM pairs ORDER BY pair_id" - ); - return ok(res, { count: rows.length, rows }); - } catch (e) { - return fail(res, 500, "DB_ERROR", "Erreur lecture table pairs", e.message); - } -}); - -/** - * Fetch + insert sur une paire ID - */ -async function fetchAndInsertTickById(pairId, preferredSource = "BINANCE") { - const pairRow = await getPairById(pairId); - const { base, quote } = parsePairCodeFromDb(pairRow.pair_code); - - const data = await getPriceWithFallback(preferredSource, { base, quote }); - - await insertPricePoint({ - pair_id: pairRow.pair_id, - timestamp_ms: data.timestamp_ms, - current_price: data.price, - source: data.source - }); - - return { - pair_id: pairRow.pair_id, - pair_code: pairRow.pair_code, - base, - quote, - source: data.source, - timestamp_ms: data.timestamp_ms, - current_price: data.price, - used_fallback: Boolean(data.used_fallback) - }; -} - -/** - * Prix courant - * GET /api/price/current?pair=BTC/EUR - */ -app.get("/api/price/current", async (req, res) => { - const pair = normalizePairCode(req.query.pair); - - if (!pair) { - return fail(res, 400, "PAIR_REQUIRED", "Parametre 'pair' obligatoire (ex: BTC/EUR)"); - } - - let pair_id; - try { - pair_id = await getActivePairIdByCode(pair); - } catch (err) { - if (err.code === "PAIR_NOT_FOUND") { - return fail(res, 404, "PAIR_NOT_FOUND", `Paire inconnue: ${pair}`); - } - return fail(res, 400, "PAIR_INVALID", `Paire invalide: ${pair}`); - } - - try { - const row = await getCurrentPrice(pair_id); - if (!row) { - return fail(res, 404, "NO_PRICE", `Aucun prix en base pour ${pair}`); - } - - return ok(res, { - pair, - pair_id, - source: row.source, - timestamp_ms: row.timestamp_ms, - current_price: row.current_price, - open_price: row.open_price, - high_price: row.high_price, - low_price: row.low_price, - close_price: row.close_price, - volume_24h: row.volume_24h ?? null - }); - } catch (err) { - return fail(res, 500, "DB_ERROR", "Erreur lecture prix courant", err.message); - } -}); - -/** - * Historique - * GET /api/price/history?pair=BTC/EUR&limit=200 - */ -app.get("/api/price/history", async (req, res) => { - const pair = normalizePairCode(req.query.pair); - const limit = req.query.limit; - - if (!pair) { - return fail(res, 400, "PAIR_REQUIRED", "Parametre 'pair' obligatoire (ex: BTC/EUR)"); - } - - let pair_id; - try { - pair_id = await getActivePairIdByCode(pair); - } catch (err) { - if (err.code === "PAIR_NOT_FOUND") { - return fail(res, 404, "PAIR_NOT_FOUND", `Paire inconnue: ${pair}`); - } - return fail(res, 400, "PAIR_INVALID", `Paire invalide: ${pair}`); - } - - try { - const points = await getPriceHistory(pair_id, limit); - return ok(res, { pair, pair_id, points }); - } catch (err) { - return fail(res, 500, "DB_ERROR", "Erreur lecture historique", err.message); - } -}); - -/** - * Refresh manuel - * POST /api/price/refresh?pair=BTC/EUR - */ -app.post("/api/price/refresh", async (req, res) => { - const pair = normalizePairCode(req.query.pair); - if (!pair) { - return fail(res, 400, "PAIR_REQUIRED", "Parametre 'pair' obligatoire (ex: BTC/EUR)"); - } - - try { - const { base, quote } = parsePairInput(pair); - const pair_id = await getActivePairIdByCode(pair); - - const data = await getPriceWithFallback("BINANCE", { base, quote }); - - await insertPricePoint({ - pair_id, - timestamp_ms: data.timestamp_ms, - current_price: data.price, - source: data.source - }); - - return ok(res, { - pair, - pair_id, - source: data.source, - timestamp_ms: data.timestamp_ms, - current_price: data.price, - used_fallback: Boolean(data.used_fallback) - }); - } catch (err) { - if (err.code === "PAIR_NOT_FOUND") { - return fail(res, 404, "PAIR_NOT_FOUND", `Paire inconnue: ${pair}`); - } - return fail(res, 503, "PROVIDER_ERROR", `Refresh impossible pour ${pair}`, formatProviderError(err)); - } -}); - -/** - * Scheduler: toutes les paires actives à chaque tick - * => limite de concurrence pour éviter rate-limit - */ -async function schedulerTick() { - if (PAIR_IDS_TO_SAMPLE.length === 0) { - console.warn("Aucune paire active à sampler (PAIR_IDS_TO_SAMPLE vide)."); - return; - } - - const preferred = "BINANCE"; - const CONCURRENCY = 5; // ajuste à 3/5/10 selon le nombre de paires - - let i = 0; - - async function worker() { - while (i < PAIR_IDS_TO_SAMPLE.length) { - const pairId = PAIR_IDS_TO_SAMPLE[i++]; - try { - const tick = await fetchAndInsertTickById(pairId, preferred); - console.log( - `Tick OK pair_id=${tick.pair_id} (${tick.pair_code}) ${tick.current_price} (${tick.source}${tick.used_fallback ? " fallback" : ""})` - ); - } catch (err) { - console.error(`Erreur scheduler pair_id=${pairId}: ${formatProviderError(err)}`); - } - } - } - - await Promise.all(Array.from({ length: CONCURRENCY }, worker)); -} - -/** - * ✅ Démarrage du serveur - */ -app.listen(PORT, async () => { - console.log(`Serveur lance : http://localhost:${PORT}`); - - try { - await loadAllActivePairIds(); // charge toutes les paires actives - await schedulerTick(); // 1 tick immédiat - setInterval(schedulerTick, SAMPLE_INTERVAL_MS); - } catch (e) { - console.error("Erreur démarrage serveur:", e.message); - } -}); \ No newline at end of file