diff --git a/backend/src/db/index.js b/backend/src/db/index.js index cda42cb..639bb13 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -14,6 +14,39 @@ fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); // ── Pending restore (déclenché par POST /api/admin/exports/:filename/restore) ─ // Si un fichier .pending-restore existe, on l'applique avant d'ouvrir la DB. const PENDING_RESTORE = DB_PATH + '.pending-restore'; + +// Tables locales à CET environnement, à préserver à travers une restauration +// (cf. feedback_restore_preserve_env_local.md) : une restauration remplace le +// fichier DB dans son intégralité, y compris ces tables — sans ce mécanisme, +// restaurer un export prod dans dev invalide silencieusement les clés API +// générées côté dev (api_keys, y compris les clés internes auto-provisionnées +// pour l'assistant IA), écrase la configuration des fournisseurs IA propres +// à dev (ai_providers/ai_config) par celle de la source restaurée, et +// écraserait de la même façon l'étiquette d'environnement de cette instance +// (parametres_environnement — Admin > Général, cf. 05/09/26) par celle de la +// source restaurée. Ordre important : ai_config référence ai_providers (FK +// provider_defaut_id). +const ENV_LOCAL_TABLES = ['api_keys', 'ai_providers', 'ai_config', 'parametres_environnement']; +let pendingRestorePreserve = null; +if (fs.existsSync(PENDING_RESTORE) && fs.existsSync(DB_PATH)) { + try { + const oldDb = new Database(DB_PATH, { readonly: true }); + const existingTables = new Set( + oldDb.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name) + ); + pendingRestorePreserve = {}; + for (const table of ENV_LOCAL_TABLES) { + if (existingTables.has(table)) { + pendingRestorePreserve[table] = oldDb.prepare(`SELECT * FROM ${table}`).all(); + } + } + oldDb.close(); + } catch (e) { + console.error('[restore] Échec de la sauvegarde des tables locales (api_keys/ai_providers/ai_config/parametres_environnement) avant restauration :', e.message); + pendingRestorePreserve = null; + } +} + if (fs.existsSync(PENDING_RESTORE)) { try { fs.copyFileSync(PENDING_RESTORE, DB_PATH); @@ -1794,6 +1827,26 @@ db.exec(` ) `); +// ── Migration : identification de l'environnement (Admin > Général) ───────── +// Demande Olivier 05/09/26 : chaque instance (prod / qualification / dev) +// affiche et enregistre son propre environnement, choisi manuellement par un +// admin — aucune inférence automatique possible ici (contrairement à +// appUrl/mcpUrl en dev via import.meta.env.DEV, cf. devOverrides.js : une +// instance de qualification tourne sur un build comme la prod, le mode DEV de +// Vite ne permet donc pas de la distinguer). Table dédiée (plutôt qu'une +// colonne de plus dans smtp_config) et ajoutée à ENV_LOCAL_TABLES ci-dessus : +// une restauration de base copiée depuis un autre environnement ne doit +// jamais réécrire l'étiquette de CET environnement-ci. +db.exec(` + CREATE TABLE IF NOT EXISTS parametres_environnement ( + id INTEGER PRIMARY KEY CHECK (id = 1), + environnement TEXT NOT NULL DEFAULT 'developpement' + CHECK (environnement IN ('production', 'qualification', 'developpement')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec(`INSERT OR IGNORE INTO parametres_environnement (id) VALUES (1)`); + // ── Migration : email_verified sur users ───────────────────────────────────── { const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); @@ -2803,6 +2856,38 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_ai_usage_date ON ai_usage_log(created_at } } +// ── Réapplication des tables locales préservées avant une restauration ── +// (cf. feedback_restore_preserve_env_local.md). Placé ici, après toutes les +// migrations api_keys/ai_providers/ai_config ci-dessus, pour que le schéma +// de la base fraîchement restaurée soit à jour avant qu'on y réinjecte le +// contenu propre à CET environnement (sinon une colonne migrée seulement en +// aval de ce point serait silencieusement perdue lors de la réinsertion). +if (pendingRestorePreserve) { + try { + const restoreEnvLocal = db.transaction(() => { + for (const table of ENV_LOCAL_TABLES) { + const rows = pendingRestorePreserve[table]; + if (!rows) continue; // table absente de l'environnement d'avant restauration + db.exec(`DELETE FROM ${table}`); + if (!rows.length) continue; + const currentCols = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name)); + // Ne garder que les colonnes existant réellement dans le schéma courant : + // un export source plus ancien ou plus récent peut avoir un jeu de + // colonnes légèrement différent. + const usableCols = Object.keys(rows[0]).filter(c => currentCols.has(c)); + const placeholders = usableCols.map(() => '?').join(','); + const insert = db.prepare(`INSERT INTO ${table} (${usableCols.join(',')}) VALUES (${placeholders})`); + for (const row of rows) insert.run(...usableCols.map(c => row[c])); + } + }); + restoreEnvLocal(); + console.log('[restore] Tables locales (api_keys/ai_providers/ai_config/parametres_environnement) préservées et réappliquées après restauration.'); + } catch (e) { + console.error('[restore] Échec de la réapplication des tables locales préservées :', e.message); + } + pendingRestorePreserve = null; +} + // Conversations de l'assistant IA — plusieurs fils nommés par couple // user/investisseur (même granularité de scope que le reste de l'appli, cf. // requireInvestisseur), consultables et supprimables individuellement diff --git a/backend/src/routes/general.js b/backend/src/routes/general.js index 604b1b8..d019496 100644 --- a/backend/src/routes/general.js +++ b/backend/src/routes/general.js @@ -27,6 +27,9 @@ function ensureRow() { // colonne applique automatiquement son DEFAULT (5 Go / 500 documents, // cf. migration dans db/index.js) quand elle est omise d'un INSERT. } + // parametres_environnement : normalement déjà créée avec sa ligne par défaut + // par la migration dans db/index.js (INSERT OR IGNORE) — filet de sécurité ici. + db.prepare('INSERT OR IGNORE INTO parametres_environnement (id) VALUES (1)').run(); } // 1 Go = 1024^3 octets (convention déjà utilisée par fmtOctets côté frontend). @@ -36,9 +39,11 @@ router.get('/', (_req, res, next) => { try { ensureRow(); const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length, documents_quota_bytes, documents_quota_count FROM smtp_config WHERE id = 1').get(); + const envRow = db.prepare('SELECT environnement FROM parametres_environnement WHERE id = 1').get(); res.json({ appName: row.app_name || 'Crowdlending Tracker', appUrl: row.app_url || '', + environnement: envRow?.environnement || 'developpement', mcpUrl: row.mcp_url || '', allowRegistration: row.allow_registration !== 0, minPasswordLength: row.min_password_length || 8, @@ -51,6 +56,7 @@ router.get('/', (_req, res, next) => { const PatchSchema = z.object({ appName: z.string().min(1).max(100).optional(), appUrl: z.string().max(500).optional(), + environnement: z.enum(['production', 'qualification', 'developpement']).optional(), mcpUrl: z.string().max(500).optional(), allowRegistration: z.boolean().optional(), minPasswordLength: z.number().int().min(6).max(64).optional(), @@ -84,6 +90,10 @@ router.patch('/', (req, res, next) => { body.documentsQuotaCount !== undefined ? body.documentsQuotaCount : (row.documents_quota_count || 500), ); + if (body.environnement !== undefined) { + db.prepare('UPDATE parametres_environnement SET environnement = ? WHERE id = 1').run(body.environnement); + } + res.json({ ok: true }); } catch (e) { next(e); } }); diff --git a/frontend/src/components/InvChart.jsx b/frontend/src/components/InvChart.jsx index e6ec861..d2349f2 100644 --- a/frontend/src/components/InvChart.jsx +++ b/frontend/src/components/InvChart.jsx @@ -48,6 +48,17 @@ export default function InvChart({ rows, remboursements, reinvestissements, plat const currentYear = String(new Date().getFullYear()); const cutoffStr = platYear && platYear !== currentYear ? `${platYear}-12-31` : todayStr; + // Coupure appliquée aux MONTANTS cumulés (formule "Capital investi" unifiée, + // cf. project_invchart_capital_encours) : bornée au 31/12/{platYear} pour une + // année passée révolue, ILLIMITÉE sinon — exactement comme les totaux KPI + // (Plateformes.jsx / Investissements.jsx, cutoff = selectedYear ? ... : null). + // Sans ça, un remboursement déjà saisi avec une date de règlement future + // (ex. Olivier enregistre le remboursement final dès que la plateforme + // l'annonce, avec la date de règlement communiquée, avant qu'elle soit + // atteinte) est compté par le KPI mais pas encore par le graphique, qui + // affiche alors un capital investi supérieur au KPI de exactement ce montant. + const dataCutoffStr = platYear && platYear !== currentYear ? `${platYear}-12-31` : null; + /* ── 1. Courbe capital en cours = investissements − capital remboursé ── */ const allPoints = useMemo(() => { if (!rows?.length) return []; @@ -61,7 +72,7 @@ export default function InvChart({ rows, remboursements, reinvestissements, plat // +montant_investi à chaque date de souscription for (const r of rows) { const d = r.date_souscription?.slice(0, 10); - if (!d || d > cutoffStr) continue; + if (!d || (dataCutoffStr && d > dataCutoffStr)) continue; deltas[d] = (deltas[d] || 0) + (r.montant_investi ?? 0); } @@ -70,7 +81,7 @@ export default function InvChart({ rows, remboursements, reinvestissements, plat for (const rv of reinvestissements) { if (!rowIds.has(rv.investissement_id)) continue; const d = rv.date_reinvestissement?.slice(0, 10); - if (!d || !rv.montant || d > cutoffStr) continue; + if (!d || !rv.montant || (dataCutoffStr && d > dataCutoffStr)) continue; deltas[d] = (deltas[d] || 0) + rv.montant; } } @@ -80,18 +91,33 @@ export default function InvChart({ rows, remboursements, reinvestissements, plat for (const rb of remboursements) { if (!rowIds.has(rb.investissement_id)) continue; const d = rb.date_remb?.slice(0, 10); - if (!d || !rb.capital || d > cutoffStr) continue; + if (!d || !rb.capital || (dataCutoffStr && d > dataCutoffStr)) continue; deltas[d] = (deltas[d] || 0) - rb.capital; } } // Tri chronologique + cumul let cum = 0; - return Object.keys(deltas).sort().map(date => { + const cumulated = Object.keys(deltas).sort().map(date => { cum += deltas[date]; return { date, value: Math.max(0, cum) }; }); - }, [rows, remboursements, reinvestissements, cutoffStr]); + + // Un point dont la date dépasse aujourd'hui (remboursement/réinvestissement + // saisi par avance, cf. dataCutoffStr) est regroupé sur la date du jour pour + // l'affichage : la courbe ne va jamais visuellement au-delà d'aujourd'hui, + // mais la valeur cumulée (déjà correcte, alignée sur le KPI) est conservée. + const clamped = []; + for (const p of cumulated) { + const d = p.date > todayStr ? todayStr : p.date; + if (clamped.length && clamped[clamped.length - 1].date === d) { + clamped[clamped.length - 1] = { date: d, value: p.value }; + } else { + clamped.push({ date: d, value: p.value }); + } + } + return clamped; + }, [rows, remboursements, reinvestissements, dataCutoffStr, todayStr]); const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0; diff --git a/frontend/src/pages/admin/GeneralSection.jsx b/frontend/src/pages/admin/GeneralSection.jsx index d6f453a..2a503df 100644 --- a/frontend/src/pages/admin/GeneralSection.jsx +++ b/frontend/src/pages/admin/GeneralSection.jsx @@ -35,6 +35,7 @@ function SectionHeader({ title, description }) { const DEFAULT = { appName: 'Crowdlending Tracker', appUrl: '', + environnement: 'developpement', mcpUrl: '', allowRegistration: true, minPasswordLength: 8, @@ -42,6 +43,12 @@ const DEFAULT = { documentsQuotaCount: 500, }; +const ENVIRONNEMENTS = [ + { value: 'production', label: 'Production' }, + { value: 'qualification', label: 'Qualification' }, + { value: 'developpement', label: 'Développement' }, +]; + export default function GeneralSection() { const [form, setForm] = useState(DEFAULT); const [saved, setSaved] = useState(null); // 'ok' | 'err' | null @@ -58,6 +65,7 @@ export default function GeneralSection() { setForm({ appName: dd.appName || 'Crowdlending Tracker', appUrl: dd.appUrl || '', + environnement: dd.environnement || 'developpement', mcpUrl: dd.mcpUrl || '', allowRegistration: dd.allowRegistration !== false, minPasswordLength: dd.minPasswordLength || 8, @@ -77,6 +85,7 @@ export default function GeneralSection() { await api.patch('/admin/general', { appName: form.appName.trim(), appUrl: form.appUrl.trim(), + environnement: form.environnement, mcpUrl: form.mcpUrl.trim(), allowRegistration: form.allowRegistration, minPasswordLength: form.minPasswordLength, @@ -131,6 +140,18 @@ export default function GeneralSection() { /> + + + +