825 lines
36 KiB
JavaScript
825 lines
36 KiB
JavaScript
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 (DATA_DIR défini), le restart policy Docker relance le conteneur
|
|
* qui applique le pending-restore au démarrage.
|
|
* → En local (DATA_DIR absent), aucun superviseur (node --watch / nodemon)
|
|
* ne relance automatiquement le process après un exit propre : on
|
|
* respawn nous-mêmes un process enfant détaché avant de quitter.
|
|
*/
|
|
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.DATA_DIR) {
|
|
// Dev local : 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).
|
|
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);
|
|
}
|
|
});
|
|
|
|
export default router;
|