import { Router } from 'express'; import bcrypt from 'bcryptjs'; import { z } from 'zod'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import db, { grantDefaultWorkspace } from '../db/index.js'; import Database from 'better-sqlite3'; import { HttpError } from '../middleware/errorHandler.js'; import { checkStatutsRetard } from '../jobs/autoStatut.js'; import { runAutoExport } from '../jobs/autoExport.js'; import { audit } from '../utils/audit.js'; import multer from 'multer'; import { createZip, readZip } from '../utils/zip.js'; import { deleteAllDocumentFilesForUser, buildAllDocumentsZipEntries } from './documents.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const dataDir = process.env.DATA_DIR ? path.resolve(process.env.DATA_DIR) : path.resolve(__dirname, '../../../data'); const dbPath = process.env.DB_PATH ? path.resolve(process.env.DB_PATH) : path.resolve(__dirname, '../../data/crowdlending.db'); // ── Helpers similarité de noms ──────────────────────────────────────────── */ /** Distance de Levenshtein entre deux chaînes */ function levenshtein(a, b) { const m = a.length, n = b.length; const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]); for (let j = 0; j <= n; j++) dp[0][j] = j; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); } } return dp[m][n]; } /** Similarité normalisée [0..1] — insensible à la casse et aux accents */ function normalize(s) { return (s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim(); } function similarity(a, b) { const na = normalize(a), nb = normalize(b); if (!na && !nb) return 1; const maxLen = Math.max(na.length, nb.length); return maxLen === 0 ? 1 : 1 - levenshtein(na, nb) / maxLen; } const SIMILARITY_THRESHOLD = 0.80; // Registre des jobs disponibles (nom → fonction) const JOBS = { auto_statut_retard: checkStatutsRetard, auto_export: runAutoExport, }; const router = Router(); // requireAuth + requireAdmin sont appliqués dans server.js avant ce router /* ── Utilisateurs ─────────────────────────────────────────────────────── */ /** Liste tous les utilisateurs, avec quelques compteurs d'usage par compte * (plateformes, investissements, documents) — demande Olivier 29/08/26, * colonnes affichées dans Administration > Utilisateurs. Sous-requêtes * corrélées plutôt que JOIN + GROUP BY : chaque compte reste une ligne * distincte sans risquer de doublons liés aux jointures multiples. */ router.get('/users', (req, res) => { const users = db.prepare(` SELECT id, email, display_name, role, email_verified, totp_enabled, status, created_at, ai_enabled, (SELECT COUNT(*) FROM plateformes p WHERE p.user_id = users.id) AS nb_plateformes, (SELECT COUNT(*) FROM investissements i JOIN investisseurs inv ON inv.id = i.investisseur_id WHERE inv.user_id = users.id) AS nb_investissements, (SELECT COUNT(*) FROM documents d WHERE d.user_id = users.id) AS nb_documents, (SELECT COALESCE(SUM(d.taille_octets), 0) FROM documents d WHERE d.user_id = users.id) AS taille_documents, (SELECT GROUP_CONCAT(workspace_id) FROM user_workspaces uw WHERE uw.user_id = users.id AND uw.granted_by_admin = 1) AS workspace_ids FROM users ORDER BY id ASC `).all(); // workspace_ids : GROUP_CONCAT renvoie une chaîne "1,2" ou null — converti ici // en tableau de nombres pour rester cohérent avec le format attendu côté // frontend (ManageRightsModal), plutôt que de parser la chaîne à chaque endroit. for (const u of users) { u.workspace_ids = u.workspace_ids ? u.workspace_ids.split(',').map(Number) : []; } res.json(users); }); /** Vérifier manuellement l'email d'un utilisateur */ router.patch('/users/:id/verify-email', (req, res, next) => { try { const targetId = Number(req.params.id); const r = db.prepare("UPDATE users SET email_verified=1, updated_at=datetime('now') WHERE id=?").run(targetId); if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable'); db.prepare('UPDATE email_verification_tokens SET used=1 WHERE user_id=? AND used=0').run(targetId); audit(req, { action: 'email_verified_admin', category: 'account', actorId: req.user.id, targetUserId: targetId }); res.json({ ok: true }); } catch (e) { next(e); } }); /** Crée un utilisateur */ const CreateUserSchema = z.object({ email: z.string().email(), password: z.string().min(8), displayName: z.string().min(1).optional(), role: z.enum(['user', 'admin']).default('user'), }); router.post('/users', (req, res, next) => { try { const body = CreateUserSchema.parse(req.body); const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(body.email); if (exists) throw new HttpError(409, 'Email déjà utilisé'); const hash = bcrypt.hashSync(body.password, 10); const result = db .prepare('INSERT INTO users (email, password_hash, display_name, role) VALUES (?, ?, ?, ?)') .run(body.email, hash, body.displayName || null, body.role); const userId = result.lastInsertRowid; const fullName = body.displayName || body.email.split('@')[0]; const prenom = fullName.includes(' ') ? fullName.split(' ')[0] : null; // Auto-créer le profil principal (= l'utilisateur lui-même), comme dans /auth/register const invResult = db.prepare( `INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, is_principal) VALUES (?, ?, ?, 'famille', 'PP', 1)` ).run(userId, fullName, prenom); // Auto-créer un compte courant pour le profil principal, comme dans /auth/register db.prepare( 'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)' ).run(userId, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid); // Accorde l'accès au workspace socle (crowdlending), comme tout nouveau compte // — cf. project_workspaces_transformation.md. grantDefaultWorkspace(userId); audit(req, { action: 'user_created', category: 'account', actorId: req.user.id, targetUserId: userId, details: { email: body.email, role: body.role, created_by_admin: true } }); res.status(201).json({ id: userId, email: body.email, display_name: body.displayName || null, role: body.role }); } catch (e) { next(e); } }); /** Modifie le statut d'un utilisateur (active / deactivated / locked) */ const PatchStatusSchema = z.object({ status: z.enum(['active', 'deactivated', 'locked']), }); router.patch('/users/:id/status', (req, res, next) => { try { const { status } = PatchStatusSchema.parse(req.body); const targetId = Number(req.params.id); if (targetId === req.user.id) { throw new HttpError(400, 'Vous ne pouvez pas modifier votre propre statut'); } const r = db.prepare("UPDATE users SET status=?, updated_at=datetime('now') WHERE id=?") .run(status, targetId); if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable'); audit(req, { action: 'status_changed', category: 'status', actorId: req.user.id, targetUserId: targetId, details: { new_status: status } }); res.json({ id: targetId, status }); } catch (e) { next(e); } }); /** Active / désactive l'accès à l'Assistant IA pour un utilisateur (décision admin) */ const PatchAiAccessSchema = z.object({ aiEnabled: z.boolean(), }); router.patch('/users/:id/ai-access', (req, res, next) => { try { const { aiEnabled } = PatchAiAccessSchema.parse(req.body); const targetId = Number(req.params.id); const r = db.prepare("UPDATE users SET ai_enabled=?, updated_at=datetime('now') WHERE id=?") .run(aiEnabled ? 1 : 0, targetId); if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable'); audit(req, { action: 'ai_access_changed', category: 'ai', actorId: req.user.id, targetUserId: targetId, details: { ai_enabled: aiEnabled } }); res.json({ id: targetId, aiEnabled }); } catch (e) { next(e); } }); /** Définit les workspaces accordés à un utilisateur (octroi admin) — remplace * intégralement l'ensemble accordé (granted_by_admin) par la liste fournie. * L'activation personnelle (active_by_user) n'est jamais touchée pour un * workspace déjà accordé, pour ne pas écraser un choix déjà fait par * l'utilisateur ; elle est mise à 1 par défaut pour un nouvel octroi. */ const PatchWorkspacesSchema = z.object({ workspaceIds: z.array(z.number().int()), }); router.patch('/users/:id/workspaces', (req, res, next) => { try { const { workspaceIds } = PatchWorkspacesSchema.parse(req.body); const targetId = Number(req.params.id); const target = db.prepare('SELECT id FROM users WHERE id = ?').get(targetId); if (!target) throw new HttpError(404, 'Utilisateur introuvable'); const applyGrants = db.transaction(() => { db.prepare('UPDATE user_workspaces SET granted_by_admin = 0 WHERE user_id = ?').run(targetId); for (const wsId of workspaceIds) { db.prepare(` INSERT INTO user_workspaces (user_id, workspace_id, granted_by_admin, active_by_user, granted_at) VALUES (?, ?, 1, 1, datetime('now')) ON CONFLICT(user_id, workspace_id) DO UPDATE SET granted_by_admin = 1, granted_at = datetime('now') `).run(targetId, wsId); } }); applyGrants(); audit(req, { action: 'workspaces_changed', category: 'workspaces', actorId: req.user.id, targetUserId: targetId, details: { workspace_ids: workspaceIds } }); res.json({ id: targetId, workspaceIds }); } catch (e) { next(e); } }); /** Modifie le rôle d'un utilisateur */ const PatchRoleSchema = z.object({ role: z.enum(['user', 'admin']), }); router.patch('/users/:id/role', (req, res, next) => { try { const { role } = PatchRoleSchema.parse(req.body); const targetId = Number(req.params.id); // Empêche un admin de se rétrograder lui-même if (targetId === req.user.id && role !== 'admin') { throw new HttpError(400, 'Vous ne pouvez pas vous rétrograder vous-même'); } const r = db.prepare("UPDATE users SET role=?, updated_at=datetime('now') WHERE id=?") .run(role, targetId); if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable'); audit(req, { action: 'role_changed', category: 'role', actorId: req.user.id, targetUserId: targetId, details: { new_role: role } }); res.json({ id: targetId, role }); } catch (e) { next(e); } }); /** Supprime un utilisateur (sauf soi-même) */ router.delete('/users/:id', (req, res, next) => { try { const targetId = Number(req.params.id); if (targetId === req.user.id) { throw new HttpError(400, 'Vous ne pouvez pas supprimer votre propre compte'); } const targetUser = db.prepare('SELECT email, display_name FROM users WHERE id = ?').get(targetId); if (!targetUser) throw new HttpError(404, 'Utilisateur introuvable'); // Fichiers documents de l'utilisateur : à effacer AVANT le DELETE FROM users // (les lignes `documents` seront supprimées en cascade par la contrainte // ON DELETE CASCADE, mais une cascade SQLite n'efface jamais les fichiers // du disque — il faut les lister pendant que le user existe encore). deleteAllDocumentFilesForUser(targetId); const r = db.prepare('DELETE FROM users WHERE id = ?').run(targetId); if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable'); audit(req, { action: 'user_deleted', category: 'account', actorId: req.user.id, details: { email: targetUser?.email, display_name: targetUser?.display_name } }); res.status(204).end(); } catch (e) { next(e); } }); /* ── Logs des jobs ────────────────────────────────────────────────────── */ router.get('/job-logs', (req, res) => { const limit = Math.min(Number(req.query.limit) || 100, 500); const offset = Number(req.query.offset) || 0; const job = req.query.job || null; const conds = job ? 'WHERE job_name = ?' : ''; const params = job ? [job, limit, offset] : [limit, offset]; const rows = db.prepare(` SELECT id, job_name, run_at, status, nb_changes, details, error_msg FROM job_logs ${conds} ORDER BY run_at DESC LIMIT ? OFFSET ? `).all(...params); const total = db.prepare(`SELECT COUNT(*) AS n FROM job_logs ${conds}`) .get(...(job ? [job] : [])).n; res.json({ total, rows }); }); /* ── Plateformes orphelines (sans référentiel) ───────────────────────── */ /** Liste les plateformes user sans referentiel_id, avec suggestion de liaison si nom similaire */ router.get('/plateformes-orphelines', (_req, res) => { const rows = db.prepare(` SELECT p.id, p.nom, p.domiciliation, p.fiscalite, p.created_at, u.id AS user_id, u.email AS user_email, u.display_name AS user_display_name FROM plateformes p JOIN users u ON u.id = p.user_id WHERE p.referentiel_id IS NULL ORDER BY u.email, p.nom `).all(); // Charger le référentiel pour calculer les suggestions de liaison const refs = db.prepare('SELECT id, nom FROM plateformes_referentiel ORDER BY nom').all(); const enriched = rows.map(plat => { let bestRef = null, bestScore = 0; for (const ref of refs) { const score = similarity(plat.nom, ref.nom); if (score > bestScore) { bestScore = score; bestRef = ref; } } return { ...plat, suggestion: bestScore >= SIMILARITY_THRESHOLD ? { referentiel_id: bestRef.id, referentiel_nom: bestRef.nom, score: Math.round(bestScore * 100) } : null, }; }); res.json(enriched); }); /** Importe une plateforme orpheline dans le référentiel et la lie */ router.post('/plateformes-orphelines/:id/importer', (req, res, next) => { try { const plat = db.prepare(` SELECT p.*, u.email AS user_email FROM plateformes p JOIN users u ON u.id = p.user_id WHERE p.id = ? `).get(req.params.id); if (!plat) throw new HttpError(404, 'Plateforme introuvable'); if (plat.referentiel_id) throw new HttpError(409, 'Plateforme deja liee a un referentiel'); // Verifier si un referentiel avec ce nom existe deja const existing = db.prepare('SELECT id FROM plateformes_referentiel WHERE nom = ?').get(plat.nom); if (existing) { // Lier simplement la plateforme au referentiel existant db.prepare( "UPDATE plateformes SET referentiel_id = ?, overridden_fields = '[]' WHERE id = ?" ).run(existing.id, plat.id); const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(existing.id); return res.json({ linked: true, created: false, referentiel: ref }); } // Creer un nouveau referentiel depuis la plateforme db.transaction(() => { const r = db.prepare(` INSERT INTO plateformes_referentiel (nom, url, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, logo_filename, updated_at) VALUES (?,?,?,?,?,?,?, datetime('now')) `).run( plat.nom, plat.url || null, plat.domiciliation, plat.fiscalite, plat.taux_fiscalite_locale ?? null, plat.type_produit_fiscal || '2TT', plat.logo_filename ?? null ); const refId = r.lastInsertRowid; // Copier les categories (noms) depuis la plateforme source const cats = db.prepare(` SELECT cp.nom FROM plateforme_categories pc JOIN categories_plateforme cp ON cp.id = pc.categorie_id WHERE pc.plateforme_id = ? `).all(plat.id); const insC = db.prepare('INSERT OR IGNORE INTO referentiel_categories (referentiel_id, categorie_nom) VALUES (?,?)'); for (const c of cats) insC.run(refId, c.nom); // Copier les criteres de notation const notations = db.prepare('SELECT * FROM notation_criteres WHERE plateforme_id = ?').all(plat.id); const insN = db.prepare(` INSERT INTO referentiel_notation (referentiel_id, nom, type, valeurs, min_val, max_val, description, ordre) VALUES (?,?,?,?,?,?,?,?) `); for (const n of notations) insN.run(refId, n.nom, n.type, n.valeurs, n.min_val, n.max_val, n.description, n.ordre); // Lier la plateforme au nouveau referentiel, overridden_fields vide db.prepare( "UPDATE plateformes SET referentiel_id = ?, overridden_fields = '[]' WHERE id = ?" ).run(refId, plat.id); })(); const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE nom = ?').get(plat.nom); res.status(201).json({ linked: true, created: true, referentiel: ref }); } catch (e) { next(e); } }); /** Lie directement une plateforme orpheline à un référentiel existant (sans import) */ router.post('/plateformes-orphelines/:id/lier', (req, res, next) => { try { const { referentiel_id } = req.body; if (!referentiel_id) throw new HttpError(400, 'referentiel_id requis'); const plat = db.prepare('SELECT id, referentiel_id FROM plateformes WHERE id = ?').get(req.params.id); if (!plat) throw new HttpError(404, 'Plateforme introuvable'); if (plat.referentiel_id) throw new HttpError(409, 'Plateforme déjà liée à un référentiel'); const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(referentiel_id); if (!ref) throw new HttpError(404, 'Référentiel introuvable'); db.prepare( "UPDATE plateformes SET referentiel_id = ?, overridden_fields = '[]' WHERE id = ?" ).run(ref.id, plat.id); res.json({ linked: true, referentiel: ref }); } catch (e) { next(e); } }); /* -- Execution manuelle d'un job ----------------------------------------- */ router.post('/jobs/:name/run', async (req, res, next) => { try { const { name } = req.params; const fn = JOBS[name]; if (!fn) throw new HttpError(404, `Job inconnu : ${name}`); await fn(); const lastLog = db.prepare( 'SELECT * FROM job_logs WHERE job_name = ? ORDER BY run_at DESC LIMIT 1' ).get(name); res.json({ ok: true, nb_changes: lastLog?.nb_changes ?? 0, log: lastLog }); } catch (e) { next(e); } }); /* ── Catégories & secteurs suggérés par les utilisateurs ─────────────── */ /** Compte les catégories et secteurs créés par les utilisateurs (non globaux) */ router.get('/inv-suggestions-count', (_req, res) => { const cats = db.prepare('SELECT COUNT(*) AS n FROM categories_inv WHERE user_id IS NOT NULL').get().n; const sects = db.prepare('SELECT COUNT(*) AS n FROM secteurs_inv WHERE user_id IS NOT NULL').get().n; res.json({ cats, sects, total: cats + sects }); }); /** Liste toutes les catégories et secteurs créés par les utilisateurs */ router.get('/inv-suggestions', (_req, res) => { const categories = db.prepare(` SELECT c.id, c.nom, c.user_id, u.email, u.display_name, (SELECT COUNT(*) FROM plateforme_categories_inv WHERE categorie_id = c.id) AS nb_plateformes, (SELECT COUNT(*) FROM investissement_categories_inv WHERE categorie_id = c.id) AS nb_investissements FROM categories_inv c JOIN users u ON u.id = c.user_id WHERE c.user_id IS NOT NULL ORDER BY u.email, c.nom `).all(); const secteurs = db.prepare(` SELECT s.id, s.nom, s.user_id, u.email, u.display_name, (SELECT COUNT(*) FROM plateforme_secteurs_inv WHERE secteur_id = s.id) AS nb_plateformes, (SELECT COUNT(*) FROM investissement_secteurs_inv WHERE secteur_id = s.id) AS nb_investissements FROM secteurs_inv s JOIN users u ON u.id = s.user_id WHERE s.user_id IS NOT NULL ORDER BY u.email, s.nom `).all(); res.json({ categories, secteurs }); }); /** Promeut une catégorie utilisateur en catégorie globale (user_id → NULL) */ router.post('/inv-suggestions/categories/:id/promouvoir', (req, res, next) => { try { const row = db.prepare('SELECT id, nom, user_id FROM categories_inv WHERE id = ?').get(req.params.id); if (!row) throw new HttpError(404, 'Catégorie introuvable'); if (row.user_id === null) throw new HttpError(400, 'Déjà globale'); const dup = db.prepare('SELECT id FROM categories_inv WHERE nom = ? AND user_id IS NULL').get(row.nom); if (dup) throw new HttpError(409, `Une catégorie globale "${row.nom}" existe déjà.`); db.prepare('UPDATE categories_inv SET user_id = NULL WHERE id = ?').run(row.id); res.json({ ok: true, msg: `"${row.nom}" promue en catégorie globale.` }); } catch (e) { next(e); } }); /** Supprime une catégorie suggérée */ router.delete('/inv-suggestions/categories/:id', (req, res, next) => { try { const row = db.prepare('SELECT id, nom, user_id FROM categories_inv WHERE id = ?').get(req.params.id); if (!row) throw new HttpError(404, 'Catégorie introuvable'); if (row.user_id === null) throw new HttpError(403, 'Ne peut pas supprimer une catégorie globale depuis cette route.'); db.transaction(() => { db.prepare('DELETE FROM plateforme_categories_inv WHERE categorie_id = ?').run(row.id); db.prepare('DELETE FROM investissement_categories_inv WHERE categorie_id = ?').run(row.id); db.prepare('DELETE FROM categories_inv WHERE id = ?').run(row.id); })(); res.json({ ok: true, msg: `"${row.nom}" supprimée.` }); } catch (e) { next(e); } }); /** Promeut un secteur utilisateur en secteur global */ router.post('/inv-suggestions/secteurs/:id/promouvoir', (req, res, next) => { try { const row = db.prepare('SELECT id, nom, user_id FROM secteurs_inv WHERE id = ?').get(req.params.id); if (!row) throw new HttpError(404, 'Secteur introuvable'); if (row.user_id === null) throw new HttpError(400, 'Déjà global'); const dup = db.prepare('SELECT id FROM secteurs_inv WHERE nom = ? AND user_id IS NULL').get(row.nom); if (dup) throw new HttpError(409, `Un secteur global "${row.nom}" existe déjà.`); db.prepare('UPDATE secteurs_inv SET user_id = NULL WHERE id = ?').run(row.id); res.json({ ok: true, msg: `"${row.nom}" promu en secteur global.` }); } catch (e) { next(e); } }); /** Supprime un secteur suggéré */ router.delete('/inv-suggestions/secteurs/:id', (req, res, next) => { try { const row = db.prepare('SELECT id, nom, user_id FROM secteurs_inv WHERE id = ?').get(req.params.id); if (!row) throw new HttpError(404, 'Secteur introuvable'); if (row.user_id === null) throw new HttpError(403, 'Ne peut pas supprimer un secteur global depuis cette route.'); db.transaction(() => { db.prepare('DELETE FROM plateforme_secteurs_inv WHERE secteur_id = ?').run(row.id); db.prepare('DELETE FROM investissement_secteurs_inv WHERE secteur_id = ?').run(row.id); db.prepare('DELETE FROM secteurs_inv WHERE id = ?').run(row.id); })(); res.json({ ok: true, msg: `"${row.nom}" supprimé.` }); } catch (e) { next(e); } }); /* ── Export complet prod → dev ────────────────────────────────────────────── */ const exportsDir = path.join(dataDir, 'exports'); const MAX_EXPORTS = 10; /** Retourne la liste des exports triée du plus récent au plus ancien */ function listExportFiles() { fs.mkdirSync(exportsDir, { recursive: true }); return fs.readdirSync(exportsDir) .filter(f => f.endsWith('.zip')) .map(f => { const stat = fs.statSync(path.join(exportsDir, f)); return { filename: f, size: stat.size, created_at: stat.mtime.toISOString() }; }) .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); } /** Supprime les exports les plus anciens au-delà de MAX_EXPORTS */ function purgeOldExports() { const files = listExportFiles(); for (const f of files.slice(MAX_EXPORTS)) { fs.unlinkSync(path.join(exportsDir, f.filename)); } } /** * GET /api/admin/export-full * Génère un export ZIP, le sauvegarde sur disque, et le retourne au navigateur. */ router.get('/export-full', (req, res, next) => { const tmpDb = path.join(os.tmpdir(), `cl-backup-${Date.now()}.db`); try { db.exec(`VACUUM INTO '${tmpDb.replace(/'/g, "''")}'`); const dbData = fs.readFileSync(tmpDb); const now = new Date(); const pad = n => String(n).padStart(2, '0'); const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` + `_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; const filename = `crowdlending-export-${ts}.zip`; const entries = []; entries.push({ name: 'manifest.json', data: JSON.stringify({ version: '1.0', app: 'crowdlending', exported_at: now.toISOString(), type: 'full-export', }, null, 2), }); entries.push({ name: 'crowdlending.db', data: dbData }); for (const subdir of ['logos', 'icons']) { const dir = path.join(dataDir, subdir); if (!fs.existsSync(dir)) continue; for (const f of fs.readdirSync(dir)) { const fpath = path.join(dir, f); if (fs.statSync(fpath).isFile()) { entries.push({ name: `${subdir}/${f}`, data: fs.readFileSync(fpath) }); } } } // Fichiers documents (tous utilisateurs) — la base SQLite ci-dessus contient // déjà les lignes `documents`, mais pas les fichiers physiques qu'elles // référencent ; cf. demande Olivier 29/08/26. entries.push(...buildAllDocumentsZipEntries()); const zipBuf = createZip(entries); // Sauvegarde sur disque + purge fs.mkdirSync(exportsDir, { recursive: true }); fs.writeFileSync(path.join(exportsDir, filename), zipBuf); purgeOldExports(); res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(zipBuf); } catch (e) { next(e); } finally { if (fs.existsSync(tmpDb)) fs.unlinkSync(tmpDb); } }); /** GET /api/admin/exports — liste des exports stockés sur le serveur */ router.get('/exports', (req, res, next) => { try { res.json(listExportFiles()); } catch (e) { next(e); } }); const exportUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 200 * 1024 * 1024 }, // 200 Mo max fileFilter: (_req, file, cb) => { if (file.mimetype === 'application/zip' || file.originalname.endsWith('.zip')) cb(null, true); else cb(new HttpError(400, 'Fichier ZIP attendu')); }, }); /** POST /api/admin/exports/upload — import d'un export depuis le client */ router.post('/exports/upload', exportUpload.single('file'), (req, res, next) => { try { if (!req.file) throw new HttpError(400, 'Aucun fichier reçu'); // Validation : le ZIP doit contenir un manifest.json avec type 'full-export' let entries; try { entries = readZip(req.file.buffer); } catch { throw new HttpError(400, 'Archive ZIP invalide ou corrompue'); } const manifestEntry = entries.find(e => e.name === 'manifest.json'); if (!manifestEntry) throw new HttpError(400, "Archive invalide : manifest.json introuvable"); let manifest; try { manifest = JSON.parse(manifestEntry.data.toString('utf8')); } catch { throw new HttpError(400, 'manifest.json illisible'); } if (manifest.type !== 'full-export') { throw new HttpError(400, `Type d'archive incorrect : "${manifest.type}" (attendu : "full-export")`); } // Sanitisation du nom de fichier let filename = path.basename(req.file.originalname); if (!filename.endsWith('.zip')) filename += '.zip'; // Évite les collisions de nom const dest = path.join(exportsDir, filename); if (fs.existsSync(dest)) { const ts = Date.now(); filename = filename.replace(/\.zip$/, `-${ts}.zip`); } fs.mkdirSync(exportsDir, { recursive: true }); fs.writeFileSync(path.join(exportsDir, filename), req.file.buffer); purgeOldExports(); res.json({ ok: true, filename, exports: listExportFiles() }); } catch (e) { next(e); } }); /** GET /api/admin/exports/:filename — téléchargement d'un export stocké */ router.get('/exports/:filename', (req, res, next) => { try { const filename = path.basename(req.params.filename); // sécurité : pas de path traversal if (!filename.endsWith('.zip')) throw new HttpError(400, 'Nom de fichier invalide'); const fpath = path.join(exportsDir, filename); if (!fs.existsSync(fpath)) throw new HttpError(404, 'Export introuvable'); res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(fs.readFileSync(fpath)); } catch (e) { next(e); } }); /** DELETE /api/admin/exports/:filename — suppression d'un export */ router.delete('/exports/:filename', (req, res, next) => { try { const filename = path.basename(req.params.filename); if (!filename.endsWith('.zip')) throw new HttpError(400, 'Nom de fichier invalide'); const fpath = path.join(exportsDir, filename); if (!fs.existsSync(fpath)) throw new HttpError(404, 'Export introuvable'); fs.unlinkSync(fpath); res.json({ ok: true }); } catch (e) { next(e); } }); /** * POST /api/admin/exports/:filename/restore * Restaure l'environnement depuis un export stocké : * 1. Sauvegarde l'état courant dans exports/ (filet de sécurité) * 2. Copie les assets (logos, icons, documents) immédiatement * 3. Écrit la nouvelle DB dans {DB_PATH}.pending-restore * 4. Répond au client, puis redémarre le processus (process.exit) * → En prod (NODE_ENV=production), le restart policy Docker relance le conteneur * qui applique le pending-restore au démarrage. * → En local (NODE_ENV≠production — dev nodemon, ou tout environnement qui ne * se déclare pas explicitement "production"), aucun superviseur externe ne * relance automatiquement le process après un exit propre (nodemon n'auto- * restart que sur changement de fichier) : on respawn nous-mêmes un process * enfant détaché avant de quitter. NB (19/09/26, bug signalé par Olivier : * "j'ai restauré, ça ne marche pas sans relancer le serveur à la main") : * le signal utilisé ici était auparavant la présence de DATA_DIR, qui n'est * PAS un indicateur fiable de "on tourne sous Docker avec restart policy" — * le .env de dev local définit lui aussi DATA_DIR (pour organiser où vivent * les données), donc la condition ne se déclenchait jamais en dev et le * process restait mort jusqu'à un redémarrage manuel. */ router.post('/exports/:filename/restore', async (req, res, next) => { const tmpDb = path.join(os.tmpdir(), `cl-pre-restore-${Date.now()}.db`); try { const filename = path.basename(req.params.filename); if (!filename.endsWith('.zip')) throw new HttpError(400, 'Nom de fichier invalide'); const fpath = path.join(exportsDir, filename); if (!fs.existsSync(fpath)) throw new HttpError(404, 'Export introuvable'); // Lecture et validation du ZIP const zipBuf = fs.readFileSync(fpath); let entries; try { entries = readZip(zipBuf); } catch { throw new HttpError(400, 'Archive ZIP invalide ou corrompue'); } const manifestEntry = entries.find(e => e.name === 'manifest.json'); if (!manifestEntry) throw new HttpError(400, 'manifest.json introuvable dans l\'archive'); let manifest; try { manifest = JSON.parse(manifestEntry.data.toString('utf8')); } catch { throw new HttpError(400, 'manifest.json illisible'); } if (manifest.type !== 'full-export') throw new HttpError(400, 'Type d\'archive incorrect'); const dbEntry = entries.find(e => e.name === 'crowdlending.db'); if (!dbEntry) throw new HttpError(400, 'crowdlending.db introuvable dans l\'archive'); // 1. Sauvegarde de sécurité de l'état courant db.exec(`VACUUM INTO '${tmpDb.replace(/'/g, "''")}'`); const backupEntries = []; backupEntries.push({ name: 'manifest.json', data: JSON.stringify({ version: '1.0', app: 'crowdlending', exported_at: new Date().toISOString(), type: 'full-export', note: 'pre-restore-backup', }, null, 2), }); backupEntries.push({ name: 'crowdlending.db', data: fs.readFileSync(tmpDb) }); // 'documents' inclus au même titre que logos/icons : c'est un filet de // sécurité, on sauvegarde tout ce qui est actuellement sur disque avant // de l'écraser à l'étape 2, indépendamment de ce que référence la base. for (const subdir of ['logos', 'icons', 'documents']) { const dir = path.join(dataDir, subdir); if (!fs.existsSync(dir)) continue; for (const f of fs.readdirSync(dir)) { const fp = path.join(dir, f); if (fs.statSync(fp).isFile()) backupEntries.push({ name: `${subdir}/${f}`, data: fs.readFileSync(fp) }); } } const pad = n => String(n).padStart(2, '0'); const now = new Date(); const ts = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; fs.mkdirSync(exportsDir, { recursive: true }); fs.writeFileSync(path.join(exportsDir, `pre-restore-backup-${ts}.zip`), createZip(backupEntries)); purgeOldExports(); // 2. Remplacement des assets (logos + icons + documents) — safe à faire en live. // 'documents' suit exactement le même traitement que logos/icons (demande // Olivier 29/08/26) : le dossier est vidé puis repeuplé depuis le zip. // Même remarque que pour logos/icons : entre ce remplacement et le // redémarrage effectif du process qui applique le pending-restore de la // base (étape 3/4), la base encore active référence des `filename` qui // viennent d'être remplacés par ceux de l'export importé — fenêtre de // cohérence transitoire déjà acceptée pour logos/icons, désormais partagée // par documents. for (const subdir of ['logos', 'icons', 'documents']) { const dir = path.join(dataDir, subdir); fs.mkdirSync(dir, { recursive: true }); // Vidage du dossier existant (fichiers et sous-dossiers) for (const f of fs.readdirSync(dir)) { const fp = path.join(dir, f); if (fs.statSync(fp).isDirectory()) fs.rmSync(fp, { recursive: true }); else fs.unlinkSync(fp); } // Écriture des nouveaux fichiers for (const entry of entries.filter(e => e.name.startsWith(`${subdir}/`) && !e.name.endsWith('/'))) { fs.writeFileSync(path.join(dir, path.basename(entry.name)), entry.data); } } // 3. Validation + écriture du pending-restore { const tmpValidate = path.join(os.tmpdir(), `cl-validate-${Date.now()}.db`); let testDb; try { fs.writeFileSync(tmpValidate, dbEntry.data); testDb = new Database(tmpValidate, { readonly: true }); const check = testDb.pragma('integrity_check'); if (!check || check[0]?.integrity_check !== 'ok') { throw new HttpError(500, `Base de données corrompue dans l'archive — integrity_check: ${JSON.stringify(check?.[0])}`); } } catch (e) { if (e instanceof HttpError) throw e; throw new HttpError(500, `Base de données invalide dans l'archive : ${e.message}`); } finally { if (testDb) try { testDb.close(); } catch {} if (fs.existsSync(tmpValidate)) try { fs.unlinkSync(tmpValidate); } catch {} } } fs.writeFileSync(dbPath + '.pending-restore', dbEntry.data); // 4. Réponse puis redémarrage res.json({ ok: true, backup: `pre-restore-backup-${ts}.zip` }); setTimeout(() => { if (process.env.NODE_ENV !== 'production') { // Dev local (ou tout NODE_ENV non explicitement "production") : node --watch / // nodemon n'auto-restart pas après un exit propre (process.exit(0)) — ils // attendent un changement de fichier. On relance donc nous-mêmes un process // enfant détaché avant de quitter, qui appliquera le .pending-restore à son // démarrage (voir db/index.js). Anciennement conditionné à `!process.env.DATA_DIR`, // qui ne détectait pas fiablement l'environnement (cf. commentaire de la route // ci-dessus) — DATA_DIR est défini en dev local aussi. try { const child = spawn(process.execPath, [process.argv[1]], { cwd: process.cwd(), env: process.env, detached: true, stdio: 'ignore', windowsHide: true, }); child.unref(); } catch (e) { console.error('[restore] échec du respawn local du serveur :', e); } } process.exit(0); }, 300); } catch (e) { next(e); } finally { if (fs.existsSync(tmpDb)) fs.unlinkSync(tmpDb); } }); /* ── Comptes orphelins (banque texte libre non reliée au référentiel institutions) ─────── Phase 2 du chantier "Référentiel Institutions financières" (cf. plan de déploiement, doc projet claude/plan_referentiel_institutions_financieres.md). Contrairement aux plateformes orphelines (une ligne par plateforme), ici on regroupe par valeur `banque` distincte car plusieurs comptes (potentiellement de plusieurs utilisateurs) partagent souvent le même texte libre — Lier/Importer agit donc sur tous les comptes portant cette valeur d'un coup. */ /** Liste les valeurs `banque` distinctes non reliées à institution_id, avec suggestion */ router.get('/comptes-orphelins', (_req, res) => { const rows = db.prepare(` SELECT c.banque, u.id AS user_id, u.email AS user_email, u.display_name AS user_display_name, COUNT(*) AS nb FROM comptes c JOIN users u ON u.id = c.user_id WHERE c.institution_id IS NULL AND c.banque IS NOT NULL AND TRIM(c.banque) != '' GROUP BY c.banque, u.id ORDER BY c.banque, u.email `).all(); const byBanque = {}; for (const r of rows) { if (!byBanque[r.banque]) byBanque[r.banque] = { banque: r.banque, total: 0, users: [] }; byBanque[r.banque].total += r.nb; byBanque[r.banque].users.push({ id: r.user_id, email: r.user_email, display_name: r.user_display_name, nb: r.nb }); } const refs = db.prepare('SELECT id, nom FROM institutions_referentiel ORDER BY nom').all(); const enriched = Object.values(byBanque).map(g => { let bestRef = null, bestScore = 0; for (const ref of refs) { const score = similarity(g.banque, ref.nom); if (score > bestScore) { bestScore = score; bestRef = ref; } } return { ...g, suggestion: bestScore >= SIMILARITY_THRESHOLD ? { institution_id: bestRef.id, institution_nom: bestRef.nom, score: Math.round(bestScore * 100) } : null, }; }).sort((a, b) => a.banque.localeCompare(b.banque, 'fr')); res.json(enriched); }); /** Lie tous les comptes portant cette valeur `banque` à une institution existante du référentiel */ router.post('/comptes-orphelins/lier', (req, res, next) => { try { const { banque, institution_id } = z.object({ banque: z.string().min(1), institution_id: z.number().int().positive(), }).parse(req.body); const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(institution_id); if (!ref) throw new HttpError(404, 'Institution introuvable'); const r = db.prepare( 'UPDATE comptes SET institution_id = ? WHERE banque = ? AND institution_id IS NULL' ).run(institution_id, banque); res.json({ linked: true, nb_comptes: r.changes, institution: ref }); } catch (e) { next(e); } }); /** Crée une nouvelle institution dans le référentiel à partir de la valeur `banque`, et lie tous les comptes correspondants (ou lie simplement si une institution de ce nom existe déjà) */ router.post('/comptes-orphelins/importer', (req, res, next) => { try { const { banque } = z.object({ banque: z.string().min(1) }).parse(req.body); let ref = db.prepare('SELECT * FROM institutions_referentiel WHERE nom = ?').get(banque); let created = false; if (!ref) { const r = db.prepare('INSERT INTO institutions_referentiel (nom) VALUES (?)').run(banque); ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(r.lastInsertRowid); created = true; } const upd = db.prepare( 'UPDATE comptes SET institution_id = ? WHERE banque = ? AND institution_id IS NULL' ).run(ref.id, banque); res.status(created ? 201 : 200).json({ linked: true, created, nb_comptes: upd.changes, institution: ref }); } catch (e) { next(e); } }); export default router;