Améliorations
This commit is contained in:
1 parent
8d104f2058
commit
bab7a79b63
4 files changed
+147
-5
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user