Améliorations

This commit is contained in:
ocroguennec committed 2026-09-05 17:05:54 +02:00
1 parent 8d104f2058
commit bab7a79b63
4 files changed
+147 -5

No files matched your search

+85
View File
@@ -14,6 +14,39 @@ fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
// ── Pending restore (déclenché par POST /api/admin/exports/:filename/restore) ─ // ── 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. // Si un fichier .pending-restore existe, on l'applique avant d'ouvrir la DB.
const PENDING_RESTORE = DB_PATH + '.pending-restore'; 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)) { if (fs.existsSync(PENDING_RESTORE)) {
try { try {
fs.copyFileSync(PENDING_RESTORE, DB_PATH); 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 ───────────────────────────────────── // ── Migration : email_verified sur users ─────────────────────────────────────
{ {
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); 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 // 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. // user/investisseur (même granularité de scope que le reste de l'appli, cf.
// requireInvestisseur), consultables et supprimables individuellement // requireInvestisseur), consultables et supprimables individuellement
+10
View File
@@ -27,6 +27,9 @@ function ensureRow() {
// colonne applique automatiquement son DEFAULT (5 Go / 500 documents, // colonne applique automatiquement son DEFAULT (5 Go / 500 documents,
// cf. migration dans db/index.js) quand elle est omise d'un INSERT. // 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). // 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 { try {
ensureRow(); 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 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({ res.json({
appName: row.app_name || 'Crowdlending Tracker', appName: row.app_name || 'Crowdlending Tracker',
appUrl: row.app_url || '', appUrl: row.app_url || '',
environnement: envRow?.environnement || 'developpement',
mcpUrl: row.mcp_url || '', mcpUrl: row.mcp_url || '',
allowRegistration: row.allow_registration !== 0, allowRegistration: row.allow_registration !== 0,
minPasswordLength: row.min_password_length || 8, minPasswordLength: row.min_password_length || 8,
@@ -51,6 +56,7 @@ router.get('/', (_req, res, next) => {
const PatchSchema = z.object({ const PatchSchema = z.object({
appName: z.string().min(1).max(100).optional(), appName: z.string().min(1).max(100).optional(),
appUrl: z.string().max(500).optional(), appUrl: z.string().max(500).optional(),
environnement: z.enum(['production', 'qualification', 'developpement']).optional(),
mcpUrl: z.string().max(500).optional(), mcpUrl: z.string().max(500).optional(),
allowRegistration: z.boolean().optional(), allowRegistration: z.boolean().optional(),
minPasswordLength: z.number().int().min(6).max(64).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), 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 }); res.json({ ok: true });
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
+31 -5
View File
@@ -48,6 +48,17 @@ export default function InvChart({ rows, remboursements, reinvestissements, plat
const currentYear = String(new Date().getFullYear()); const currentYear = String(new Date().getFullYear());
const cutoffStr = platYear && platYear !== currentYear ? `${platYear}-12-31` : todayStr; 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é ── */ /* ── 1. Courbe capital en cours = investissements − capital remboursé ── */
const allPoints = useMemo(() => { const allPoints = useMemo(() => {
if (!rows?.length) return []; if (!rows?.length) return [];
@@ -61,7 +72,7 @@ export default function InvChart({ rows, remboursements, reinvestissements, plat
// +montant_investi à chaque date de souscription // +montant_investi à chaque date de souscription
for (const r of rows) { for (const r of rows) {
const d = r.date_souscription?.slice(0, 10); 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); 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) { for (const rv of reinvestissements) {
if (!rowIds.has(rv.investissement_id)) continue; if (!rowIds.has(rv.investissement_id)) continue;
const d = rv.date_reinvestissement?.slice(0, 10); 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; deltas[d] = (deltas[d] || 0) + rv.montant;
} }
} }
@@ -80,18 +91,33 @@ export default function InvChart({ rows, remboursements, reinvestissements, plat
for (const rb of remboursements) { for (const rb of remboursements) {
if (!rowIds.has(rb.investissement_id)) continue; if (!rowIds.has(rb.investissement_id)) continue;
const d = rb.date_remb?.slice(0, 10); 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; deltas[d] = (deltas[d] || 0) - rb.capital;
} }
} }
// Tri chronologique + cumul // Tri chronologique + cumul
let cum = 0; let cum = 0;
return Object.keys(deltas).sort().map(date => { const cumulated = Object.keys(deltas).sort().map(date => {
cum += deltas[date]; cum += deltas[date];
return { date, value: Math.max(0, cum) }; 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; const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0;
@@ -35,6 +35,7 @@ function SectionHeader({ title, description }) {
const DEFAULT = { const DEFAULT = {
appName: 'Crowdlending Tracker', appName: 'Crowdlending Tracker',
appUrl: '', appUrl: '',
environnement: 'developpement',
mcpUrl: '', mcpUrl: '',
allowRegistration: true, allowRegistration: true,
minPasswordLength: 8, minPasswordLength: 8,
@@ -42,6 +43,12 @@ const DEFAULT = {
documentsQuotaCount: 500, documentsQuotaCount: 500,
}; };
const ENVIRONNEMENTS = [
{ value: 'production', label: 'Production' },
{ value: 'qualification', label: 'Qualification' },
{ value: 'developpement', label: 'Développement' },
];
export default function GeneralSection() { export default function GeneralSection() {
const [form, setForm] = useState(DEFAULT); const [form, setForm] = useState(DEFAULT);
const [saved, setSaved] = useState(null); // 'ok' | 'err' | null const [saved, setSaved] = useState(null); // 'ok' | 'err' | null
@@ -58,6 +65,7 @@ export default function GeneralSection() {
setForm({ setForm({
appName: dd.appName || 'Crowdlending Tracker', appName: dd.appName || 'Crowdlending Tracker',
appUrl: dd.appUrl || '', appUrl: dd.appUrl || '',
environnement: dd.environnement || 'developpement',
mcpUrl: dd.mcpUrl || '', mcpUrl: dd.mcpUrl || '',
allowRegistration: dd.allowRegistration !== false, allowRegistration: dd.allowRegistration !== false,
minPasswordLength: dd.minPasswordLength || 8, minPasswordLength: dd.minPasswordLength || 8,
@@ -77,6 +85,7 @@ export default function GeneralSection() {
await api.patch('/admin/general', { await api.patch('/admin/general', {
appName: form.appName.trim(), appName: form.appName.trim(),
appUrl: form.appUrl.trim(), appUrl: form.appUrl.trim(),
environnement: form.environnement,
mcpUrl: form.mcpUrl.trim(), mcpUrl: form.mcpUrl.trim(),
allowRegistration: form.allowRegistration, allowRegistration: form.allowRegistration,
minPasswordLength: form.minPasswordLength, minPasswordLength: form.minPasswordLength,
@@ -131,6 +140,18 @@ export default function GeneralSection() {
/> />
</SettingRow> </SettingRow>
<SettingRow label="Environnement" description="Identifie cette instance (affiché dans l'interface). Choisi manuellement par un administrateur : conservé tel quel lors d'une restauration de base copiée depuis un autre environnement.">
<select
value={form.environnement}
onChange={e => set('environnement', e.target.value)}
style={{ width: '100%' }}
>
{ENVIRONNEMENTS.map(({ value, label }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</SettingRow>
<SettingRow label="URL du serveur MCP" description={`Point d'entrée public du serveur MCP (endpoint /mcp inclus). Inclure le protocole (https://).${import.meta.env.DEV ? ' Valeur forcée en développement local (ignore celle de la base, potentiellement une copie de la prod).' : ''}`}> <SettingRow label="URL du serveur MCP" description={`Point d'entrée public du serveur MCP (endpoint /mcp inclus). Inclure le protocole (https://).${import.meta.env.DEV ? ' Valeur forcée en développement local (ignore celle de la base, potentiellement une copie de la prod).' : ''}`}>
<input <input
className="form-input" className="form-input"