diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 71f776a..bc3c717 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -2532,4 +2532,48 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_pertes_inv ON investissement_pertes(inve } } +// ── Migration : table documents ─────────────────────────────────────────── +// Pièces jointes de l'utilisateur (compte de login) — pour l'instant +// uniquement des documents rattachés à un investissement (entity_type +// ='investissement', entity_id=investissements.id), mais entity_type/ +// entity_id/categorie restent génériques pour accueillir d'autres types de +// documents à l'avenir (demande Olivier 29/08/26). Quota de stockage et de +// nombre de documents par utilisateur — valeur fixe globale pour l'instant, +// cf. QUOTA_BYTES/QUOTA_COUNT dans backend/src/routes/documents.js. +db.exec(` + CREATE TABLE IF NOT EXISTS documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + categorie TEXT NOT NULL DEFAULT 'investissement', + entity_type TEXT, + entity_id INTEGER, + nom_affichage TEXT NOT NULL, + nom_original TEXT NOT NULL, + extension TEXT NOT NULL, + mime_type TEXT, + taille_octets INTEGER NOT NULL, + filename TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id)'); +db.exec('CREATE INDEX IF NOT EXISTS idx_documents_entity ON documents(entity_type, entity_id)'); + +// ── Migration : quotas documentaires configurables (Admin > Général) ────── +// Demande Olivier 29/08/26 : plutôt qu'une valeur fixe codée en dur dans +// backend/src/routes/documents.js, le quota (par utilisateur — même valeur +// pour tous les comptes, pas de personnalisation individuelle pour l'instant) +// est stocké sur la ligne unique de smtp_config, comme le reste des +// paramètres généraux (app_name, app_url...), et lu à la volée par +// documents.js à chaque requête. Défauts alignés sur les anciennes +// constantes : 5 Go / 500 documents. +const smtpCols = db.prepare("PRAGMA table_info(smtp_config)").all().map(c => c.name); +if (!smtpCols.includes('documents_quota_bytes')) { + db.exec('ALTER TABLE smtp_config ADD COLUMN documents_quota_bytes INTEGER NOT NULL DEFAULT 5368709120'); // 5 Go (Gio) +} +if (!smtpCols.includes('documents_quota_count')) { + db.exec('ALTER TABLE smtp_config ADD COLUMN documents_quota_count INTEGER NOT NULL DEFAULT 500'); +} + export default db; diff --git a/backend/src/jobs/autoExport.js b/backend/src/jobs/autoExport.js index 3ad613a..37fa0c6 100644 --- a/backend/src/jobs/autoExport.js +++ b/backend/src/jobs/autoExport.js @@ -10,6 +10,7 @@ import os from 'node:os'; import { fileURLToPath } from 'node:url'; import db from '../db/index.js'; import { createZip } from '../utils/zip.js'; +import { buildAllDocumentsZipEntries } from '../routes/documents.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const dataDir = process.env.DATA_DIR @@ -85,13 +86,20 @@ export async function runAutoExport() { } } + // 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. Même helper que la route + // manuelle GET /api/admin/export-full, pour ne pas dupliquer cette logique. + const documentEntries = buildAllDocumentsZipEntries(); + for (const entry of documentEntries) entries.push(entry); + const zipBuf = createZip(entries); fs.mkdirSync(exportsDir, { recursive: true }); fs.writeFileSync(path.join(exportsDir, filename), zipBuf); purgeOldExports(); const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1); - const details = `Fichier : ${filename} | Taille : ${(zipBuf.length / 1024).toFixed(0)} Ko | Assets : ${assetCount} | Durée : ${elapsed}s`; + const details = `Fichier : ${filename} | Taille : ${(zipBuf.length / 1024).toFixed(0)} Ko | Assets : ${assetCount} | Documents : ${documentEntries.length} | Durée : ${elapsed}s`; writeLog({ status: 'ok', nbChanges: 1, details }); console.log(`[autoExport] Export terminé en ${elapsed}s → ${filename}`); } catch (e) { diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index b5faca4..3a8f575 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -14,6 +14,7 @@ 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 @@ -64,10 +65,21 @@ const router = Router(); /* ── Utilisateurs ─────────────────────────────────────────────────────── */ -/** Liste tous les 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 + SELECT + id, email, display_name, role, email_verified, totp_enabled, status, created_at, + (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 FROM users ORDER BY id ASC `).all(); @@ -176,6 +188,14 @@ router.delete('/users/:id', (req, res, next) => { 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 } }); @@ -497,6 +517,11 @@ router.get('/export-full', (req, res, next) => { } } + // 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 @@ -597,7 +622,7 @@ router.delete('/exports/:filename', (req, res, next) => { * 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) immédiatement + * 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 @@ -642,7 +667,10 @@ router.post('/exports/:filename/restore', async (req, res, next) => { }, null, 2), }); backupEntries.push({ name: 'crowdlending.db', data: fs.readFileSync(tmpDb) }); - for (const subdir of ['logos', 'icons']) { + // '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)) { @@ -657,8 +685,16 @@ router.post('/exports/:filename/restore', async (req, res, next) => { fs.writeFileSync(path.join(exportsDir, `pre-restore-backup-${ts}.zip`), createZip(backupEntries)); purgeOldExports(); - // 2. Remplacement des assets (logos + icons) — safe à faire en live - for (const subdir of ['logos', 'icons']) { + // 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) diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 522a935..5cce195 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -11,6 +11,7 @@ const _require = createRequire(import.meta.url); const QRCode = _require('qrcode'); import { generateSecret as totpGenerateSecret, generateURI as totpGenerateURI, verifySync as totpVerifySync } from 'otplib'; import { audit } from '../utils/audit.js'; +import { deleteAllDocumentFilesForUser } from './documents.js'; const router = Router(); @@ -313,6 +314,12 @@ router.delete('/me', requireAuth, (req, res, next) => { notifyTx(otherAdmins); } + // 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(user.id); + // Suppression définitive — cascade en base sur toutes les données liées // (investisseurs, plateformes, investissements, remboursements, comptes, // préférences, notifications, tickets, appareils de confiance, etc.) diff --git a/backend/src/routes/documents.js b/backend/src/routes/documents.js new file mode 100644 index 0000000..9759a74 --- /dev/null +++ b/backend/src/routes/documents.js @@ -0,0 +1,359 @@ +import { Router } from 'express'; +import multer from 'multer'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; +import { createZip, sanitizeZipPart } from '../utils/zip.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// ── Stockage fichiers ────────────────────────────────────────────────────── +// Même convention que backend/src/routes/tickets.js (pièces jointes support) : +// répertoire plat sous data/, nom de fichier sur disque randomisé, nom +// d'origine + métadonnées conservés en base pour l'affichage/téléchargement. +const dataDir = process.env.DATA_DIR + ? path.resolve(process.env.DATA_DIR) + : path.resolve(__dirname, '../../../data'); +// Exporté : réutilisé par investissements.js pour lire les fichiers lors de +// l'export ZIP d'un dossier d'investissement (manifest.json + documents). +export const docsDir = path.join(dataDir, 'documents'); +fs.mkdirSync(docsDir, { recursive: true }); + +// ── Quotas (par utilisateur/compte — même valeur pour tous les comptes, +// pas de personnalisation individuelle pour l'instant). Configurables depuis +// Admin > Général (colonnes documents_quota_bytes/documents_quota_count sur +// smtp_config, cf. migration dans db/index.js) — getQuotaConfig() relit la +// valeur courante à chaque requête, DEFAULT ci-dessous seulement si la ligne +// smtp_config n'existe pas encore (Général jamais ouvert). Le plafond par +// fichier reste une constante — non demandé configurable. ───────────────── +const DEFAULT_QUOTA_BYTES = 5 * 1024 * 1024 * 1024; // 5 Go +const DEFAULT_QUOTA_COUNT = 500; +export const MAX_FILE_BYTES = 20 * 1024 * 1024; // 20 Mo par fichier — exporté : réutilisé par imports.js pour vérifier chaque document d'un dossier importé + +export function getQuotaConfig() { + const row = db.prepare('SELECT documents_quota_bytes, documents_quota_count FROM smtp_config WHERE id = 1').get(); + return { + quotaBytes: row?.documents_quota_bytes || DEFAULT_QUOTA_BYTES, + quotaCount: row?.documents_quota_count || DEFAULT_QUOTA_COUNT, + }; +} + +const storage = multer.diskStorage({ + destination: (_, __, cb) => cb(null, docsDir), + filename: (_, file, cb) => { + const ext = path.extname(file.originalname); + const base = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + cb(null, base + ext); + }, +}); +const upload = multer({ storage, limits: { fileSize: MAX_FILE_BYTES } }); + +const router = Router(); + +/** Usage courant (nombre de documents + octets) pour un utilisateur. + * Exporté : réutilisé par imports.js (import de dossier d'investissement) + * pour vérifier le quota avant d'écrire les documents du zip importé. */ +export function getUsage(userId) { + return db.prepare( + 'SELECT COUNT(*) AS used_count, COALESCE(SUM(taille_octets),0) AS used_bytes FROM documents WHERE user_id = ?' + ).get(userId); +} + +/* ── GET /api/documents ────────────────────────────────────────────────── + Sans filtre : tous les documents du compte (utilisé par "Mon compte" — + décision Olivier : la vue d'ensemble couvre tous les investisseurs du + compte, pas seulement l'investisseur actif), avec le contexte investissement + /investisseur en plus pour s'y retrouver. + Avec ?entity_type=&entity_id= : documents d'une entité précise (utilisé par + le bloc "Mes documents" de la fiche investissement). ─────────────────── */ +router.get('/', (req, res, next) => { + try { + const { entity_type, entity_id } = req.query; + let rows; + if (entity_type && entity_id) { + rows = db.prepare( + 'SELECT * FROM documents WHERE user_id = ? AND entity_type = ? AND entity_id = ? ORDER BY created_at DESC' + ).all(req.user.id, entity_type, Number(entity_id)); + } else { + rows = db.prepare(` + SELECT d.*, i.nom_projet AS investissement_nom, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom + FROM documents d + LEFT JOIN investissements i ON d.entity_type = 'investissement' AND i.id = d.entity_id + LEFT JOIN investisseurs inv ON inv.id = i.investisseur_id + WHERE d.user_id = ? + ORDER BY d.created_at DESC + `).all(req.user.id); + } + res.json(rows); + } catch (e) { next(e); } +}); + +/* ── GET /api/documents/quota ──────────────────────────────────────────── + Consommation courante + limites, pour l'affichage dans "Mon compte". ── */ +router.get('/quota', (req, res, next) => { + try { + const usage = getUsage(req.user.id); + const { quotaBytes, quotaCount } = getQuotaConfig(); + res.json({ + used_bytes: usage.used_bytes, + used_count: usage.used_count, + quota_bytes: quotaBytes, + quota_count: quotaCount, + max_file_bytes: MAX_FILE_BYTES, + }); + } catch (e) { next(e); } +}); + +/* ── GET /api/documents/export ─────────────────────────────────────────── + Export ZIP de tous les documents du compte, organisés en dossiers selon + la même logique de regroupement que "Mon compte" > "Mes documents" côté + frontend : // pour les documents liés à un + investissement, / sinon — demande Olivier 29/08/26. + Utilise le builder ZIP maison (backend/src/utils/zip.js, déjà utilisé par + les exports plateformes/référentiel), pas de dépendance externe. ─────── */ +const CATEGORIE_FOLDER_LABELS = { investissement: 'Investissements', fiscalite: 'Fiscalite', autre: 'Autres' }; + +router.get('/export', (req, res, next) => { + try { + const rows = db.prepare(` + SELECT d.*, i.nom_projet AS investissement_nom + FROM documents d + LEFT JOIN investissements i ON d.entity_type = 'investissement' AND i.id = d.entity_id + WHERE d.user_id = ? + ORDER BY d.created_at DESC + `).all(req.user.id); + + if (rows.length === 0) throw new HttpError(400, 'Aucun document à exporter'); + + const usedNamesByFolder = new Map(); // dossier -> Set des noms déjà pris (gestion des doublons) + const entries = []; + + for (const doc of rows) { + const catFolder = sanitizeZipPart(CATEGORIE_FOLDER_LABELS[doc.categorie] || doc.categorie || 'Autres'); + const folder = (doc.entity_type === 'investissement' && doc.entity_id) + ? `${catFolder}/${sanitizeZipPart(doc.investissement_nom || `Investissement #${doc.entity_id}`)}` + : catFolder; + + const baseLabel = sanitizeZipPart(doc.nom_affichage); + const used = usedNamesByFolder.get(folder) || new Set(); + let finalName = `${baseLabel}.${doc.extension}`; + let n = 2; + while (used.has(finalName)) { finalName = `${baseLabel} (${n}).${doc.extension}`; n++; } + used.add(finalName); + usedNamesByFolder.set(folder, used); + + const filePath = path.join(docsDir, doc.filename); + if (!fs.existsSync(filePath)) continue; // fichier disparu du disque — ignoré plutôt que d'échouer tout l'export + entries.push({ name: `${folder}/${finalName}`, data: fs.readFileSync(filePath) }); + } + + const zipBuf = createZip(entries); + const filename = `documents-${new Date().toISOString().slice(0, 10)}.zip`; + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(zipBuf); + } catch (e) { next(e); } +}); + +/* ── POST /api/documents ───────────────────────────────────────────────── + multipart/form-data : file (obligatoire), entity_type, entity_id, + categorie (optionnelle), nom_affichage (optionnel — par défaut le nom du + fichier sans son extension, cf. demande Olivier). ────────────────────── */ +router.post('/', (req, res, next) => { + upload.single('file')(req, res, (err) => { + if (err) { + if (err.code === 'LIMIT_FILE_SIZE') { + return next(new HttpError(400, `Le fichier dépasse la taille maximale autorisée (${Math.round(MAX_FILE_BYTES / 1024 / 1024)} Mo).`)); + } + return next(err); + } + handleUpload(req, res, next); + }); +}); + +function handleUpload(req, res, next) { + const cleanup = () => { if (req.file) { try { fs.unlinkSync(req.file.path); } catch { /* déjà absent */ } } }; + try { + if (!req.file) throw new HttpError(400, 'Aucun fichier reçu'); + + const entityType = req.body.entity_type || null; + const entityId = req.body.entity_id ? Number(req.body.entity_id) : null; + const categorie = req.body.categorie?.trim() || (entityType === 'investissement' ? 'investissement' : 'autre'); + + // Un document lié à un investissement doit appartenir à l'utilisateur + // (même vérification de propriété que le reste de l'API — jointure via + // investisseurs.user_id, cf. backend/src/routes/investissements.js). + if (entityType === 'investissement') { + if (!entityId) throw new HttpError(400, 'entity_id est requis pour la catégorie investissement'); + const owned = db.prepare(` + SELECT i.id FROM investissements i + JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ? + WHERE i.id = ? + `).get(req.user.id, entityId); + if (!owned) throw new HttpError(404, 'Investissement introuvable'); + } + + const usage = getUsage(req.user.id); + const { quotaBytes, quotaCount } = getQuotaConfig(); + if (usage.used_count + 1 > quotaCount) { + throw new HttpError(400, `Quota de documents atteint (${quotaCount} maximum) — supprimez des documents existants avant d'en ajouter de nouveaux.`); + } + if (usage.used_bytes + req.file.size > quotaBytes) { + throw new HttpError(400, `Quota de stockage dépassé (${(quotaBytes / 1024 / 1024 / 1024).toFixed(0)} Go maximum) — supprimez des documents existants avant d'en ajouter de nouveaux.`); + } + + const ext = (path.extname(req.file.originalname).replace(/^\./, '') || 'bin').toLowerCase(); + const nomOriginalSansExt = req.file.originalname.replace(/\.[^./\\]+$/, ''); + const nomAffichage = req.body.nom_affichage?.trim() || nomOriginalSansExt || req.file.originalname; + + const r = db.prepare(` + INSERT INTO documents (user_id, categorie, entity_type, entity_id, nom_affichage, nom_original, extension, mime_type, taille_octets, filename) + VALUES (?,?,?,?,?,?,?,?,?,?) + `).run(req.user.id, categorie, entityType, entityId, nomAffichage, req.file.originalname, ext, req.file.mimetype || null, req.file.size, req.file.filename); + + res.status(201).json(db.prepare('SELECT * FROM documents WHERE id = ?').get(r.lastInsertRowid)); + } catch (e) { + cleanup(); + next(e); + } +} + +/* ── PUT /api/documents/:id ────────────────────────────────────────────── + Renommage (nom_affichage uniquement — le fichier stocké et son extension + ne changent pas). ──────────────────────────────────────────────────── */ +router.put('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT id FROM documents WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); + if (!row) throw new HttpError(404, 'Document introuvable'); + const nomAffichage = req.body?.nom_affichage?.trim(); + if (!nomAffichage) throw new HttpError(400, 'nom_affichage est requis'); + db.prepare("UPDATE documents SET nom_affichage = ?, updated_at = datetime('now') WHERE id = ?").run(nomAffichage, req.params.id); + res.json(db.prepare('SELECT * FROM documents WHERE id = ?').get(req.params.id)); + } catch (e) { next(e); } +}); + +/* ── DELETE /api/documents ──────────────────────────────────────────────── + "Tout effacer" — supprime TOUS les documents du compte, toutes catégories + et tous projets confondus (menu ⋮ du bloc "Mes documents" dans Mon compte) + — demande Olivier 29/08/26. L'avertissement d'irréversibilité est affiché + côté frontend avant l'appel (confirmation). ───────────────────────────── */ +router.delete('/', (req, res, next) => { + try { + const rows = db.prepare('SELECT * FROM documents WHERE user_id = ?').all(req.user.id); + db.prepare('DELETE FROM documents WHERE user_id = ?').run(req.user.id); + for (const doc of rows) { + try { fs.unlinkSync(path.join(docsDir, doc.filename)); } catch { /* fichier déjà absent — pas bloquant */ } + } + res.json({ deleted: rows.length }); + } catch (e) { next(e); } +}); + +/** + * Supprime les documents d'une entité (lignes `documents` + fichiers sur + * disque) — factorisé pour être réutilisé hors de ce routeur : suppression + * d'un investissement (investissements.js) et purge des données d'une + * plateforme (plateformes.js), qui n'ont sinon aucun moyen de nettoyer les + * documents liés (entity_id est un lien polymorphe, sans contrainte de clé + * étrangère — rien ne les supprime automatiquement). Retourne le nombre de + * documents supprimés. + */ +export function deleteDocumentsForEntity(userId, entityType, entityId) { + const rows = db.prepare( + 'SELECT * FROM documents WHERE user_id = ? AND entity_type = ? AND entity_id = ?' + ).all(userId, entityType, entityId); + db.prepare( + 'DELETE FROM documents WHERE user_id = ? AND entity_type = ? AND entity_id = ?' + ).run(userId, entityType, entityId); + for (const doc of rows) { + try { fs.unlinkSync(path.join(docsDir, doc.filename)); } catch { /* fichier déjà absent — pas bloquant */ } + } + return rows.length; +} + +/** + * Efface du disque les fichiers de TOUS les documents d'un utilisateur, sans + * toucher aux lignes en base — à appeler juste AVANT un `DELETE FROM users` + * (suppression de compte, admin ou self-service) : les lignes `documents` + * sont supprimées automatiquement par la contrainte `ON DELETE CASCADE` sur + * `documents.user_id`, mais une cascade SQLite n'efface jamais les fichiers + * du disque — il faut donc les lister nous-mêmes pendant que le user (et ses + * documents) existent encore, puis les effacer. Retourne le nombre de + * fichiers effacés. + */ +export function deleteAllDocumentFilesForUser(userId) { + const rows = db.prepare('SELECT filename FROM documents WHERE user_id = ?').all(userId); + for (const doc of rows) { + try { fs.unlinkSync(path.join(docsDir, doc.filename)); } catch { /* fichier déjà absent — pas bloquant */ } + } + return rows.length; +} + +/** + * Construit les entrées zip pour TOUS les documents de TOUS les utilisateurs + * (pas de filtre user_id) — utilisé par l'export complet admin (route manuelle + * ET job planifié quotidien, cf. admin.js /export-full et jobs/autoExport.js) : + * ces exports embarquent déjà la base SQLite entière via VACUUM INTO (donc les + * lignes `documents`), mais jusqu'ici pas les fichiers physiques qu'elles + * référencent. Chaque fichier existant sur disque devient une entrée + * `documents/`, même convention que les dossiers `logos/`/`icons/` + * déjà présents dans cet export. Un fichier disparu du disque (référencé en + * base mais absent) est ignoré plutôt que de faire échouer tout l'export. + */ +export function buildAllDocumentsZipEntries() { + const rows = db.prepare('SELECT filename FROM documents').all(); + const entries = []; + for (const { filename } of rows) { + const filePath = path.join(docsDir, filename); + if (fs.existsSync(filePath)) entries.push({ name: `documents/${filename}`, data: fs.readFileSync(filePath) }); + } + return entries; +} + +/* ── DELETE /api/documents/by-entity/:entityType/:entityId ─────────────── + "Tout supprimer" scopé à une entité (ex. tous les documents d'un + investissement) — déclaré avant /:id pour éviter toute ambiguïté de route + bien que les deux formes ne se chevauchent pas (nombre de segments + différent). ─────────────────────────────────────────────────────────── */ +router.delete('/by-entity/:entityType/:entityId', (req, res, next) => { + try { + const { entityType, entityId } = req.params; + if (entityType === 'investissement') { + const owned = db.prepare(` + SELECT i.id FROM investissements i + JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ? + WHERE i.id = ? + `).get(req.user.id, entityId); + if (!owned) throw new HttpError(404, 'Investissement introuvable'); + } + const deleted = deleteDocumentsForEntity(req.user.id, entityType, Number(entityId)); + res.json({ deleted }); + } catch (e) { next(e); } +}); + +/* ── GET /api/documents/:id/download ─────────────────────────────────── */ +router.get('/:id/download', (req, res, next) => { + try { + const doc = db.prepare('SELECT * FROM documents WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); + if (!doc) throw new HttpError(404, 'Document introuvable'); + const filePath = path.join(docsDir, doc.filename); + const safeName = `${doc.nom_affichage}.${doc.extension}`.replace(/["\r\n]/g, ''); + res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`); + if (doc.mime_type) res.setHeader('Content-Type', doc.mime_type); + res.sendFile(filePath, (err) => { if (err && !res.headersSent) next(err); }); + } catch (e) { next(e); } +}); + +/* ── DELETE /api/documents/:id ────────────────────────────────────────── */ +router.delete('/:id', (req, res, next) => { + try { + const doc = db.prepare('SELECT * FROM documents WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); + if (!doc) throw new HttpError(404, 'Document introuvable'); + db.prepare('DELETE FROM documents WHERE id = ?').run(req.params.id); + try { fs.unlinkSync(path.join(docsDir, doc.filename)); } catch { /* fichier déjà absent — pas bloquant */ } + res.json({ deleted: true }); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/general.js b/backend/src/routes/general.js index 72fc362..604b1b8 100644 --- a/backend/src/routes/general.js +++ b/backend/src/routes/general.js @@ -23,51 +23,65 @@ function ensureRow() { allow_unauth, app_name, app_url, allow_registration, min_password_length) VALUES (1, 0, '', 587, 0, '', '', '', 0, 'Crowdlending Tracker', '', 1, 8) `).run(); + // documents_quota_bytes/documents_quota_count non listées ci-dessus : la + // colonne applique automatiquement son DEFAULT (5 Go / 500 documents, + // cf. migration dans db/index.js) quand elle est omise d'un INSERT. } } +// 1 Go = 1024^3 octets (convention déjà utilisée par fmtOctets côté frontend). +const GO = 1024 ** 3; + router.get('/', (_req, res, next) => { try { ensureRow(); - const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length 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(); res.json({ - appName: row.app_name || 'Crowdlending Tracker', - appUrl: row.app_url || '', - mcpUrl: row.mcp_url || '', - allowRegistration: row.allow_registration !== 0, - minPasswordLength: row.min_password_length || 8, + appName: row.app_name || 'Crowdlending Tracker', + appUrl: row.app_url || '', + mcpUrl: row.mcp_url || '', + allowRegistration: row.allow_registration !== 0, + minPasswordLength: row.min_password_length || 8, + documentsQuotaGo: Math.round(((row.documents_quota_bytes || 5 * GO) / GO) * 100) / 100, + documentsQuotaCount: row.documents_quota_count || 500, }); } catch (e) { next(e); } }); const PatchSchema = z.object({ - appName: z.string().min(1).max(100).optional(), - appUrl: z.string().max(500).optional(), - mcpUrl: z.string().max(500).optional(), - allowRegistration: z.boolean().optional(), - minPasswordLength: z.number().int().min(6).max(64).optional(), + appName: z.string().min(1).max(100).optional(), + appUrl: z.string().max(500).optional(), + mcpUrl: z.string().max(500).optional(), + allowRegistration: z.boolean().optional(), + minPasswordLength: z.number().int().min(6).max(64).optional(), + documentsQuotaGo: z.number().positive().max(1000).optional(), + documentsQuotaCount: z.number().int().positive().max(100000).optional(), }); router.patch('/', (req, res, next) => { try { ensureRow(); const body = PatchSchema.parse(req.body); - const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length 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(); db.prepare(` UPDATE smtp_config SET - app_name = ?, - app_url = ?, - mcp_url = ?, - allow_registration = ?, - min_password_length = ? + app_name = ?, + app_url = ?, + mcp_url = ?, + allow_registration = ?, + min_password_length = ?, + documents_quota_bytes = ?, + documents_quota_count = ? WHERE id = 1 `).run( - body.appName !== undefined ? body.appName : (row.app_name || 'Crowdlending Tracker'), - body.appUrl !== undefined ? body.appUrl : (row.app_url || ''), - body.mcpUrl !== undefined ? body.mcpUrl : (row.mcp_url || ''), - body.allowRegistration !== undefined ? (body.allowRegistration ? 1 : 0) : (row.allow_registration !== 0 ? 1 : 0), - body.minPasswordLength !== undefined ? body.minPasswordLength : (row.min_password_length || 8), + body.appName !== undefined ? body.appName : (row.app_name || 'Crowdlending Tracker'), + body.appUrl !== undefined ? body.appUrl : (row.app_url || ''), + body.mcpUrl !== undefined ? body.mcpUrl : (row.mcp_url || ''), + body.allowRegistration !== undefined ? (body.allowRegistration ? 1 : 0) : (row.allow_registration !== 0 ? 1 : 0), + body.minPasswordLength !== undefined ? body.minPasswordLength : (row.min_password_length || 8), + body.documentsQuotaGo !== undefined ? Math.round(body.documentsQuotaGo * GO) : (row.documents_quota_bytes || 5 * GO), + body.documentsQuotaCount !== undefined ? body.documentsQuotaCount : (row.documents_quota_count || 500), ); res.json({ ok: true }); diff --git a/backend/src/routes/imports.js b/backend/src/routes/imports.js index f24fed3..6bda458 100644 --- a/backend/src/routes/imports.js +++ b/backend/src/routes/imports.js @@ -13,6 +13,8 @@ import { generateSimul, generateSimulWithReinvestissements, adjustSimulForActual import { recordHistory, detectChangements, detectTypeEvenement } from './investissements.js'; import { checkDonneesIncompletes } from '../jobs/checkDonneesIncompletes.js'; import { syncInvestissementStatut } from './remboursements.js'; +import { readZip } from '../utils/zip.js'; +import { docsDir, getUsage, getQuotaConfig, MAX_FILE_BYTES } from './documents.js'; const router = Router(); @@ -24,6 +26,15 @@ const upload = multer({ limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB }); +// Instance dédiée à l'import de dossier d'investissement (zip manifest.json + +// documents, cf. GET /api/investissements/:id/export) : limite plus haute que +// les imports xlsx/csv classiques car le zip embarque aussi les fichiers du +// dossier, pas seulement des lignes tabulaires. +const dossierUpload = multer({ + dest: UPLOAD_DIR, + limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB +}); + /** * Step 1: POST /api/imports/preview * multipart/form-data with `file` (xlsx/csv) @@ -626,26 +637,90 @@ router.post('/template', (req, res, next) => { }); /** - * POST /api/imports/dossier - * Importe un dossier investissement complet (format d'export natif). - * Scénario CREATE : le dossier n'existe pas → création complète. - * Scénario UPDATE : le dossier existe déjà → mise à jour des champs + remboursements manquants. - * Identification : (investisseur_id, nom_projet, date_souscription) — clé naturelle portable. + * Lit et valide un zip de dossier d'investissement (manifest.json + documents/, + * cf. GET /api/investissements/:id/export). Partagé par /dossier/preview et + * /dossier/apply pour ne parser le zip qu'à un seul endroit. */ -router.post('/dossier', (req, res, next) => { +function parseDossierZip(zipPath) { + const buffer = fs.readFileSync(zipPath); + let zipEntries; + try { + zipEntries = readZip(buffer); + } catch { + throw new HttpError(400, 'Fichier zip invalide ou corrompu'); + } + const manifestEntry = zipEntries.find(e => e.name === 'manifest.json'); + if (!manifestEntry) throw new HttpError(400, 'Zip invalide — manifest.json introuvable'); + + let manifest; + try { + manifest = JSON.parse(manifestEntry.data.toString('utf8')); + } catch { + throw new HttpError(400, 'manifest.json illisible (JSON invalide)'); + } + if (!manifest || manifest.type !== 'dossier_investissement') { + throw new HttpError(400, 'Format invalide — attendu un zip dont le manifest.json est de type "dossier_investissement"'); + } + return { manifest, zipEntries }; +} + +/** + * POST /api/imports/dossier/preview + * Étape 1 : dépose le zip exporté depuis la fiche investissement (bouton + * « Exporter » du bloc Informations du projet), le valide et renvoie son + * contenu pour aperçu avant confirmation. Le fichier reste sur disque + * (tempId) jusqu'à l'appel de /dossier/apply — même principe que le couple + * /preview + /apply utilisé pour les imports xlsx/csv plus haut dans ce fichier. + */ +router.post('/dossier/preview', dossierUpload.single('file'), (req, res, next) => { + try { + if (!req.file) throw new HttpError(400, 'Fichier zip requis'); + const { manifest } = parseDossierZip(req.file.path); + res.json({ tempId: path.basename(req.file.path), manifest }); + } catch (e) { next(e); } +}); + +/** + * POST /api/imports/dossier/apply + * Étape 2 : importe pour de bon le dossier déposé à l'étape /preview (tempId). + * Scénario CREATE : le dossier n'existe pas → création complète. + * Scénario UPDATE : le dossier existe déjà → mise à jour des champs + ajout des + * remboursements / réinvestissements / catégories / secteurs / révisions / + * pertes / documents manquants — la déduplication à chaque étape garantit + * qu'un réimport répété du même zip ne crée jamais de doublons. + * Identification du dossier : (investisseur_id, nom_projet, date_souscription) + * — clé naturelle portable. L'investisseur est celui actuellement sélectionné + * dans l'application (X-Investisseur-Id), pas celui décrit dans le manifeste + * (cf. décision Olivier : le rattachement suit le contexte courant de + * l'utilisateur, pas les données du fichier importé). + * Les projections (simulation) du manifeste ne sont pas réimportées : la + * simulation est régénérée après coup à partir des données réelles, comme + * pour toute création/modification d'investissement. + */ +router.post('/dossier/apply', (req, res, next) => { + const writtenFiles = []; // fichiers documents déjà écrits sur disque pendant cette requête — nettoyés si erreur ensuite try { requireInvestisseur(req, res, () => {}); - const { dossier } = req.body || {}; - if (!dossier || dossier.type !== 'dossier_investissement') { - throw new HttpError(400, 'Format invalide — attendu { dossier: { type: "dossier_investissement", ... } }'); - } - const { investissement: inv, plateforme: platInfo, remboursements = [], reinvestissements = [], historique = [] } = dossier; + const { tempId } = req.body || {}; + if (!tempId) throw new HttpError(400, 'tempId est requis'); + const tempPath = path.join(UPLOAD_DIR, tempId); + if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Fichier déposé expiré — redéposez le zip'); + + const { manifest: dossier, zipEntries } = parseDossierZip(tempPath); + + const { + investissement: inv, plateforme: platInfo, + remboursements = [], reinvestissements = [], historique = [], + categories_inv: categoriesNoms = [], secteurs_inv: secteursNoms = [], + revisions = [], pertes = [], documents: documentsMeta = [], + } = dossier; if (!inv?.nom_projet || !inv?.date_souscription) { throw new HttpError(400, 'Champs obligatoires manquants : nom_projet, date_souscription'); } let action, investissementId; + let docsInserted = 0, docsSkipped = 0; const tx = db.transaction(() => { /* ── 1. Résoudre / créer la plateforme ─────────────────── */ @@ -672,8 +747,9 @@ router.post('/dossier', (req, res, next) => { INSERT INTO investissements (investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription, date_premiere_echeance, date_cible, date_debut_simul, montant_investi, - taux_interet, duree_mois, type_remb, freq_interets, statut, reference, source, notes) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'import_dossier', ?) + taux_interet, duree_mois, type_remb, freq_interets, statut, reference, source, notes, + pays_exposition, methode_remboursement, echeance_fin_de_mois, nom_compte_courant) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'import_dossier', ?, ?,?,?,?) `).run( req.investisseur.id, plateformeId, inv.nom_projet, inv.emetteur || null, @@ -682,6 +758,8 @@ router.post('/dossier', (req, res, next) => { Number(inv.montant_investi), inv.taux_interet ?? null, inv.duree_mois ?? null, inv.type_remb || 'in_fine', inv.freq_interets || 'mensuel', inv.statut || 'en_cours', inv.reference || null, inv.notes || null, + inv.pays_exposition || 'FR', inv.methode_remboursement || null, + inv.echeance_fin_de_mois ? 1 : 0, inv.nom_compte_courant || null, ); investissementId = Number(r.lastInsertRowid); @@ -763,7 +841,8 @@ router.post('/dossier', (req, res, next) => { date_premiere_echeance = ?, date_cible = ?, date_debut_simul = ?, montant_investi = ?, taux_interet = ?, duree_mois = ?, type_remb = ?, freq_interets = ?, statut = ?, - reference = ?, notes = ? + reference = ?, notes = ?, + pays_exposition = ?, methode_remboursement = ?, echeance_fin_de_mois = ?, nom_compte_courant = ? WHERE id = ? `).run( nouveau.plateforme_id, inv.emetteur || null, @@ -771,6 +850,8 @@ router.post('/dossier', (req, res, next) => { nouveau.montant_investi, nouveau.taux_interet, nouveau.duree_mois, nouveau.type_remb, nouveau.freq_interets, nouveau.statut, inv.reference || null, inv.notes || null, + inv.pays_exposition || 'FR', inv.methode_remboursement || null, + inv.echeance_fin_de_mois ? 1 : 0, inv.nom_compte_courant || null, investissementId, ); @@ -848,11 +929,130 @@ router.post('/dossier', (req, res, next) => { action = 'updated'; } + + /* ── Catégories / secteurs d'investissement (résolution par nom, + trouve-ou-crée ; association idempotente via INSERT OR IGNORE sur + la table de jonction — aucun doublon possible même en cas de + réimport répété du même zip) ─────────────────────────────── */ + const findOrCreateCategorieInv = (nom) => { + let row = db.prepare('SELECT id FROM categories_inv WHERE nom = ?').get(nom); + if (!row) { + const r = db.prepare('INSERT INTO categories_inv (nom) VALUES (?)').run(nom); + row = { id: r.lastInsertRowid }; + } + return row.id; + }; + const findOrCreateSecteurInv = (nom) => { + let row = db.prepare('SELECT id FROM secteurs_inv WHERE nom = ?').get(nom); + if (!row) { + const r = db.prepare('INSERT INTO secteurs_inv (nom) VALUES (?)').run(nom); + row = { id: r.lastInsertRowid }; + } + return row.id; + }; + const insCatInv = db.prepare('INSERT OR IGNORE INTO investissement_categories_inv (investissement_id, categorie_id) VALUES (?, ?)'); + const insSectInv = db.prepare('INSERT OR IGNORE INTO investissement_secteurs_inv (investissement_id, secteur_id) VALUES (?, ?)'); + for (const nom of categoriesNoms) if (nom) insCatInv.run(investissementId, findOrCreateCategorieInv(nom)); + for (const nom of secteursNoms) if (nom) insSectInv.run(investissementId, findOrCreateSecteurInv(nom)); + + /* ── Révisions : ajouter les manquantes (clé = date_effet + motif) ── */ + for (const rv of revisions) { + if (!rv.date_effet || !rv.motif) continue; + const exists = db.prepare( + 'SELECT id FROM investissement_revisions WHERE investissement_id = ? AND date_effet = ? AND motif = ?' + ).get(investissementId, rv.date_effet, rv.motif); + if (!exists) { + db.prepare(` + INSERT INTO investissement_revisions + (investissement_id, date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible, motif) + VALUES (?,?,?,?,?,?,?) + `).run( + investissementId, rv.date_effet, + rv.ancien_taux ?? null, rv.nouveau_taux ?? null, + rv.ancienne_date_cible ?? null, rv.nouvelle_date_cible ?? null, + rv.motif, + ); + } + } + + /* ── Pertes : ajouter les manquantes (clé = date_effet + motif) ──── */ + for (const p of pertes) { + if (!p.date_effet || !p.motif || p.montant_perte == null) continue; + const exists = db.prepare( + 'SELECT id FROM investissement_pertes WHERE investissement_id = ? AND date_effet = ? AND motif = ?' + ).get(investissementId, p.date_effet, p.motif); + if (!exists) { + db.prepare(` + INSERT INTO investissement_pertes (investissement_id, date_effet, montant_perte, ancien_statut, motif) + VALUES (?,?,?,?,?) + `).run(investissementId, p.date_effet, p.montant_perte, p.ancien_statut ?? null, p.motif); + } + } + + /* ── Documents : recrée les fichiers absents. Déduplication par + (entity, nom_original, taille_octets) : un réimport répété du + même zip ne recrée jamais un document déjà présent. ────────── */ + const docsToInsert = []; + for (const docMeta of documentsMeta) { + const already = db.prepare(` + SELECT id FROM documents + WHERE user_id = ? AND entity_type = 'investissement' AND entity_id = ? + AND nom_original = ? AND taille_octets = ? + `).get(req.user.id, investissementId, docMeta.nom_original || null, docMeta.taille_octets ?? null); + if (already) { docsSkipped++; continue; } + + const zipEntry = zipEntries.find(e => e.name === docMeta.zip_path); + if (!zipEntry) continue; // référencé dans le manifeste mais absent du zip — ignoré silencieusement + + // Même plafond par fichier que l'upload normal d'un document — un + // zip de dossier ne doit pas permettre de contourner cette limite. + if (zipEntry.data.length > MAX_FILE_BYTES) { + throw new HttpError(400, `Le document « ${docMeta.nom_original || docMeta.zip_path} » dépasse la taille maximale autorisée par fichier (${Math.round(MAX_FILE_BYTES / 1024 / 1024)} Mo).`); + } + docsToInsert.push({ docMeta, zipEntry }); + } + + // Vérification du quota AVANT d'écrire le moindre fichier sur disque — + // même garde-fou que handleUpload() dans documents.js pour l'upload + // normal d'un document, jusqu'ici absent de l'import de dossier + // (un dossier importé pouvait donc dépasser le quota configuré). + // Vérifié en une seule fois pour tout le lot de documents à insérer. + if (docsToInsert.length > 0) { + const usage = getUsage(req.user.id); + const { quotaBytes, quotaCount } = getQuotaConfig(); + const addedBytes = docsToInsert.reduce((sum, { zipEntry }) => sum + zipEntry.data.length, 0); + if (usage.used_count + docsToInsert.length > quotaCount) { + throw new HttpError(400, `Quota de documents atteint (${quotaCount} maximum) — ce dossier ajouterait ${docsToInsert.length} document(s), supprimez des documents existants avant de réessayer.`); + } + if (usage.used_bytes + addedBytes > quotaBytes) { + throw new HttpError(400, `Quota de stockage dépassé (${(quotaBytes / 1024 / 1024 / 1024).toFixed(0)} Go maximum) — ce dossier ajouterait ${(addedBytes / 1024 / 1024).toFixed(1)} Mo, supprimez des documents existants avant de réessayer.`); + } + } + + for (const { docMeta, zipEntry } of docsToInsert) { + const ext = (docMeta.extension || '').toLowerCase(); + const diskName = `${Date.now()}-${Math.random().toString(36).slice(2)}${ext ? '.' + ext : ''}`; + const diskPath = path.join(docsDir, diskName); + fs.writeFileSync(diskPath, zipEntry.data); + writtenFiles.push(diskPath); + + db.prepare(` + INSERT INTO documents (user_id, categorie, entity_type, entity_id, nom_affichage, nom_original, extension, mime_type, taille_octets, filename) + VALUES (?,?,?,?,?,?,?,?,?,?) + `).run( + req.user.id, docMeta.categorie || 'investissement', 'investissement', investissementId, + docMeta.nom_affichage || docMeta.nom_original || diskName, + docMeta.nom_original || docMeta.nom_affichage || diskName, + ext, docMeta.mime_type || null, + docMeta.taille_octets ?? zipEntry.data.length, + diskName, + ); + docsInserted++; + } }); tx(); /* ── 3. Régénérer la simulation ─────────────────────────── */ - const fresh = db.prepare('SELECT * FROM investissements WHERE id = ?').get(investissementId); generateSimulWithReinvestissements(db, investissementId); /* ── 4. Log import ──────────────────────────────────────── */ @@ -864,11 +1064,19 @@ router.post('/dossier', (req, res, next) => { 'dossier_investissement', `Dossier_${inv.nom_projet}`, 1, 1, 0, - JSON.stringify({ action, investissementId }), + JSON.stringify({ action, investissementId, docsInserted, docsSkipped }), ); - res.json({ action, investissementId }); - } catch (e) { next(e); } + try { fs.unlinkSync(tempPath); } catch { /* déjà absent */ } + + res.json({ action, investissementId, docsInserted, docsSkipped }); + } catch (e) { + // Une erreur après écriture de documents sur disque n'annule pas ces + // écritures (seule la transaction DB est annulée automatiquement par + // better-sqlite3) : on nettoie pour ne pas laisser de fichiers orphelins. + for (const f of writtenFiles) { try { fs.unlinkSync(f); } catch { /* déjà absent */ } } + next(e); + } }); router.get('/history', (req, res) => { diff --git a/backend/src/routes/investissements.js b/backend/src/routes/investissements.js index 67407f5..91288cd 100644 --- a/backend/src/routes/investissements.js +++ b/backend/src/routes/investissements.js @@ -1,10 +1,14 @@ import { Router } from 'express'; import { z } from 'zod'; +import path from 'node:path'; +import fs from 'node:fs'; import db from '../db/index.js'; import { HttpError } from '../middleware/errorHandler.js'; import { requireInvestisseur } from '../middleware/investisseurScope.js'; import { generateSimul, generateSimulWithReinvestissements, monthsDiff, adjustSimulForActuals, replayAdjustSimulForActuals } from '../utils/schedule.js'; import { checkStatutsRetard } from '../jobs/autoStatut.js'; +import { createZip, sanitizeZipPart } from '../utils/zip.js'; +import { docsDir, deleteDocumentsForEntity } from './documents.js'; const router = Router(); @@ -488,6 +492,173 @@ router.get('/:id', (req, res, next) => { } catch (e) { next(e); } }); +/* ── GET /api/investissements/:id/export ───────────────────────────────── + Export ZIP complet d'un "dossier" d'investissement — demande Olivier + 29/08/26, rework du bouton "Exporter" du menu ⋮ (bloc "Informations du + projet"), qui ne produisait jusqu'ici qu'un simple .json construit + côté client, sans les documents joints. Contenu du zip : + - manifest.json : toutes les données nécessaires pour recréer le dossier + (investissement, plateforme, investisseur, catégories/secteurs, + remboursements, projections, réinvestissements, historique, révisions, + pertes) + les métadonnées de chaque document (nom, taille, type, et son + chemin dans le zip) — en vue d'une future réimportation (pas encore + implémentée, cf. décision Olivier : seulement l'export pour l'instant). + - documents/. : les fichiers eux-mêmes, lus sur disque via + docsDir (partagé avec documents.js). Toujours produit même sans aucun + document — contrairement à l'export global de Mon compte + (GET /api/documents/export), qui lui exige au moins un document + puisque son seul contenu, ce sont les fichiers. ────────────────────── */ +router.get('/:id/export', (req, res, next) => { + try { + const inv = db.prepare(` + SELECT i.*, p.nom AS plateforme_nom, cp.nom AS categorie_nom, + c.nom AS compte_nom, c.type AS compte_type + FROM investissements i + JOIN plateformes p ON p.id = i.plateforme_id + JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ? + LEFT JOIN categories_plateforme cp ON cp.id = i.categorie_id + LEFT JOIN comptes c ON c.id = i.compte_id + WHERE i.id = ? + `).get(req.user.id, req.params.id); + if (!inv) throw new HttpError(404, 'Not found'); + + const investisseur = db.prepare( + 'SELECT nom, prenom, type, type_fiscal, notes, email FROM investisseurs WHERE id = ? AND user_id = ?' + ).get(inv.investisseur_id, req.user.id); + + const remboursements = db.prepare( + 'SELECT * FROM remboursements WHERE investissement_id = ? ORDER BY date_remb' + ).all(req.params.id); + const simul = db.prepare( + 'SELECT * FROM simul_remboursements WHERE investissement_id = ? ORDER BY numero_echeance' + ).all(req.params.id); + const historique = db.prepare( + 'SELECT * FROM investissement_historique WHERE investissement_id = ? ORDER BY created_at ASC' + ).all(req.params.id).map(h => ({ ...h, changements: JSON.parse(h.changements) })); + const revisions = db.prepare( + 'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id ASC' + ).all(req.params.id); + const pertes = db.prepare( + 'SELECT * FROM investissement_pertes WHERE investissement_id = ? ORDER BY id ASC' + ).all(req.params.id); + const reinvestissements = db.prepare( + 'SELECT * FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement' + ).all(req.params.id); + const categories_inv = db.prepare(` + SELECT c.nom FROM investissement_categories_inv ic + JOIN categories_inv c ON c.id = ic.categorie_id + WHERE ic.investissement_id = ? + ORDER BY c.nom + `).all(req.params.id).map(r => r.nom); + const secteurs_inv = db.prepare(` + SELECT s.nom FROM investissement_secteurs_inv is2 + JOIN secteurs_inv s ON s.id = is2.secteur_id + WHERE is2.investissement_id = ? + ORDER BY s.nom + `).all(req.params.id).map(r => r.nom); + + const documents = db.prepare( + "SELECT * FROM documents WHERE user_id = ? AND entity_type = 'investissement' AND entity_id = ? ORDER BY created_at ASC" + ).all(req.user.id, req.params.id); + + // Fichiers du zip + métadonnées correspondantes dans le manifeste (le nom + // affiché sert de nom de fichier dans le zip, avec gestion des doublons — + // même logique que l'export global de Mon compte, cf. documents.js). + const usedNames = new Set(); + const documentsMeta = []; + const entries = []; + for (const doc of documents) { + const baseLabel = sanitizeZipPart(doc.nom_affichage); + let finalName = `${baseLabel}.${doc.extension}`; + let n = 2; + while (usedNames.has(finalName)) { finalName = `${baseLabel} (${n}).${doc.extension}`; n++; } + usedNames.add(finalName); + const zipPath = `documents/${finalName}`; + + documentsMeta.push({ + nom_affichage: doc.nom_affichage, + nom_original: doc.nom_original, + extension: doc.extension, + mime_type: doc.mime_type, + taille_octets: doc.taille_octets, + categorie: doc.categorie, + zip_path: zipPath, + }); + + const filePath = path.join(docsDir, doc.filename); + if (fs.existsSync(filePath)) entries.push({ name: zipPath, data: fs.readFileSync(filePath) }); + } + + const manifest = { + version: '1.0', + type: 'dossier_investissement', + exported_at: new Date().toISOString(), + investissement: { + nom_projet: inv.nom_projet, + emetteur: inv.emetteur, + date_souscription: inv.date_souscription, + date_premiere_echeance: inv.date_premiere_echeance, + date_cible: inv.date_cible, + date_debut_simul: inv.date_debut_simul, + montant_investi: inv.montant_investi, + taux_interet: inv.taux_interet, + duree_mois: inv.duree_mois, + type_remb: inv.type_remb, + freq_interets: inv.freq_interets, + statut: inv.statut, + reference: inv.reference, + source: inv.source, + notes: inv.notes, + pays_exposition: inv.pays_exposition, + methode_remboursement: inv.methode_remboursement, + echeance_fin_de_mois: inv.echeance_fin_de_mois, + nom_compte_courant: inv.nom_compte_courant, + categorie: inv.categorie_nom || null, + compte: inv.compte_nom ? { nom: inv.compte_nom, type: inv.compte_type } : null, + }, + plateforme: { nom: inv.plateforme_nom }, + investisseur: investisseur || null, + categories_inv, + secteurs_inv, + remboursements: remboursements.map(r => ({ + date_remb: r.date_remb, capital: r.capital, cashback: r.cashback, + interets_bruts: r.interets_bruts, prelev_sociaux: r.prelev_sociaux, + prelev_forfaitaire: r.prelev_forfaitaire, interets_nets: r.interets_nets, + net_recu: r.net_recu, statut: r.statut, notes: r.notes, + })), + projections: simul.map(s => ({ + numero_echeance: s.numero_echeance, date_prevue: s.date_prevue, + capital_prevu: s.capital_prevu, interets_prevus: s.interets_prevus, total_prevu: s.total_prevu, + })), + reinvestissements: reinvestissements.map(r => ({ + date_reinvestissement: r.date_reinvestissement, montant: r.montant, source: r.source, note: r.note, + })), + historique: historique.map(h => ({ + type_evenement: h.type_evenement, changements: h.changements, notes: h.notes, created_at: h.created_at, + })), + revisions: revisions.map(r => ({ + date_effet: r.date_effet, ancien_taux: r.ancien_taux, nouveau_taux: r.nouveau_taux, + ancienne_date_cible: r.ancienne_date_cible, nouvelle_date_cible: r.nouvelle_date_cible, + motif: r.motif, created_at: r.created_at, + })), + pertes: pertes.map(p => ({ + date_effet: p.date_effet, montant_perte: p.montant_perte, ancien_statut: p.ancien_statut, + motif: p.motif, created_at: p.created_at, + })), + documents: documentsMeta, + }; + + entries.unshift({ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) }); + + const zipBuf = createZip(entries); + const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const filename = `Dossier_Investissement_${req.params.id}_${ts}.zip`; + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(zipBuf); + } catch (e) { next(e); } +}); + router.post('/', (req, res, next) => { try { const body = Schema.parse(req.body); @@ -617,6 +788,12 @@ router.put('/:id/fiscalite-override', (req, res, next) => { router.delete('/:id', (req, res, next) => { try { + // Documents liés à cet investissement (lignes + fichiers sur disque) : + // nettoyés avant l'investissement lui-même. entity_id est un lien + // polymorphe sans contrainte de clé étrangère — rien ne les supprimerait + // sinon, ils resteraient orphelins (demande Olivier 29/08/26). + deleteDocumentsForEntity(req.user.id, 'investissement', Number(req.params.id)); + const r = db.prepare(` DELETE FROM investissements WHERE id = ? AND investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?) diff --git a/backend/src/routes/plateformes.js b/backend/src/routes/plateformes.js index ca6ac9f..f7efa1d 100644 --- a/backend/src/routes/plateformes.js +++ b/backend/src/routes/plateformes.js @@ -3,6 +3,7 @@ import { z } from 'zod'; import db from '../db/index.js'; import { HttpError } from '../middleware/errorHandler.js'; import { createZip, readZip } from '../utils/zip.js'; +import { deleteDocumentsForEntity } from './documents.js'; import multer from 'multer'; import path from 'node:path'; import fs from 'node:fs'; @@ -722,7 +723,7 @@ router.post('/:id/purge-donnees', (req, res, next) => { } const platId = plat.id; - const counts = { depots_retraits: 0, investissements: 0, remboursements: 0, simul_remboursements: 0 }; + const counts = { depots_retraits: 0, investissements: 0, remboursements: 0, simul_remboursements: 0, documents: 0 }; const tx = db.transaction(() => { if (scope === 'depots_retraits' || scope === 'all') { @@ -736,6 +737,12 @@ router.post('/:id/purge-donnees', (req, res, next) => { `).run(platId).changes; } if (scope === 'investissements' || scope === 'all') { + // Récupérés avant suppression : sert à nettoyer les documents liés à + // ces investissements juste après (aucune contrainte de clé étrangère + // ne les supprime automatiquement, cf. entity_id polymorphe) — une + // fois les investissements supprimés, leurs ids ne sont plus retrouvables. + const invIds = db.prepare('SELECT id FROM investissements WHERE plateforme_id = ?').all(platId).map(r => r.id); + // Compte avant suppression : les remboursements et l'échéancier simulé sont // supprimés en cascade (ON DELETE CASCADE) par la suppression des investissements. counts.remboursements = db.prepare(` @@ -747,6 +754,11 @@ router.post('/:id/purge-donnees', (req, res, next) => { WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?) `).get(platId).n; counts.investissements = db.prepare('DELETE FROM investissements WHERE plateforme_id = ?').run(platId).changes; + + // Documents liés à ces investissements (lignes + fichiers sur disque). + for (const invId of invIds) { + counts.documents += deleteDocumentsForEntity(req.user.id, 'investissement', invId); + } } }); tx(); diff --git a/backend/src/server.js b/backend/src/server.js index d58fdc9..222b535 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -54,6 +54,7 @@ import notificationsRouter from './routes/notifications.js'; import ticketsRouter from './routes/tickets.js'; import apiKeysRouter from './routes/apiKeys.js'; import v1Router from './routes/v1/index.js'; +import documentsRouter from './routes/documents.js'; import { requireApiKey } from './middleware/apiKey.js'; import { swaggerSpec, swaggerUi } from './swagger.js'; import db from './db/index.js'; @@ -157,6 +158,7 @@ app.use('/api', requireAuth, associationsInvRouter); app.use('/api/notifications', notificationsRouter); app.use('/api/tickets', requireAuth, ticketsRouter); app.use('/api/api-keys', requireAuth, apiKeysRouter); +app.use('/api/documents', requireAuth, documentsRouter); app.use(errorHandler); diff --git a/backend/src/utils/zip.js b/backend/src/utils/zip.js index 58f2b25..5dcf56e 100644 --- a/backend/src/utils/zip.js +++ b/backend/src/utils/zip.js @@ -28,6 +28,15 @@ function dosDateTime(d = new Date()) { return { time, date }; } +// ── Nettoyage d'un segment de chemin ZIP ──────────────────────────────────── +// Retire les caractères interdits dans un nom de fichier/dossier cross-platform +// (\ / : * ? " < > | + caractères de contrôle) tout en conservant accents et +// espaces. Partagé par tous les exports ZIP de l'app (documents, investissements...) +// pour que la même règle de nommage s'applique partout. +export function sanitizeZipPart(name) { + return String(name).replace(/[\\/:*?"<>|\u0000-\u001F]/g, ' ').replace(/\s+/g, ' ').trim() || 'Sans nom'; +} + /** * createZip(entries) → Buffer * entries: [{ name: string, data: Buffer|string }] @@ -54,7 +63,14 @@ export function createZip(entries) { const lh = Buffer.alloc(30 + nameBuf.length); lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); - lh.writeUInt16LE(0, 6); + // Bit 11 (0x0800) = "Language encoding flag (EFS)" — indique que le nom + // de fichier est encodé en UTF-8. Sans ce bit, les extracteurs qui ne + // devinent pas l'UTF-8 par défaut (Windows Explorer notamment) réinterprètent + // les octets UTF-8 des noms accentués avec la page de code système, d'où le + // mojibake constaté par Olivier ("Billet_électronique" -> "Billet_ Ⓡlectronique"). + // Les noms sont toujours écrits en UTF-8 ci-dessus (Buffer.from(name, 'utf8')), + // ce bit est donc toujours correct à poser. + lh.writeUInt16LE(0x0800, 6); lh.writeUInt16LE(method, 8); lh.writeUInt16LE(modTime, 10); lh.writeUInt16LE(modDate, 12); @@ -77,7 +93,7 @@ export function createZip(entries) { cd.writeUInt32LE(0x02014b50, 0); cd.writeUInt16LE(20, 4); cd.writeUInt16LE(20, 6); - cd.writeUInt16LE(0, 8); + cd.writeUInt16LE(0x0800, 8); // même flag UTF-8 (EFS) que l'en-tête local, cf. commentaire ci-dessus cd.writeUInt16LE(h.method, 10); cd.writeUInt16LE(h.modTime, 12); cd.writeUInt16LE(h.modDate, 14); @@ -113,6 +129,25 @@ export function createZip(entries) { return Buffer.concat(chunks); } +// ── Protection contre les "zip bombs" ─────────────────────────────────── +// readZip() est utilisé pour relire des zips produits en interne par l'appli +// (exports/sauvegardes admin — contenu maîtrisé) MAIS AUSSI pour des zips +// envoyés directement par un utilisateur (import de dossier d'investissement, +// import PFU/plateformes/référentiel) — donc potentiellement malveillants. +// Un zip conçu avec des données très répétitives peut annoncer quelques Ko +// compressés pour plusieurs Go décompressés (ratio extrême), ce qui saturerait +// la mémoire du process si rien ne le limite. Deux garde-fous complémentaires : +// 1. Rejet précoce si la taille décompressée ANNONCÉE dans l'en-tête central +// dépasse déjà la limite (évite même de lancer la décompression). +// 2. `maxOutputLength` passé à zlib : filet de sécurité si l'en-tête ment sur +// la taille annoncée — zlib interrompt la décompression dès que la sortie +// réelle dépasse la limite, sans jamais allouer plus que nécessaire. +// Limites généreuses par rapport à l'usage réel de l'appli (sauvegarde complète +// = base + logos/icons/documents, quelques dizaines de Mo en pratique) tout en +// bornant strictement le pire cas. +export const MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES = 500 * 1024 * 1024; // 500 Mo par entrée +export const MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES = 1024 * 1024 * 1024; // 1 Go cumulé par zip + /** * readZip(buffer) → [{ name: string, data: Buffer }] */ @@ -129,6 +164,7 @@ export function readZip(buffer) { const entries = []; let pos = cdOffset; + let totalUncompressed = 0; for (let i = 0; i < entryCount; i++) { if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('ZIP invalide : signature Central Directory incorrecte'); @@ -141,13 +177,23 @@ export function readZip(buffer) { const localOffset = buffer.readUInt32LE(pos + 42); const name = buffer.toString('utf8', pos + 46, pos + 46 + nameLen); + if (uncompSize > MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES) { + throw new Error(`ZIP invalide : l'entrée "${name}" dépasse la taille décompressée maximale autorisée (${Math.round(MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES / 1024 / 1024)} Mo).`); + } + totalUncompressed += uncompSize; + if (totalUncompressed > MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES) { + throw new Error(`ZIP invalide : la taille décompressée totale dépasse la limite autorisée (${Math.round(MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES / 1024 / 1024)} Mo).`); + } + // Read local file header to get actual extra field length const localNameLen = buffer.readUInt16LE(localOffset + 26); const localExtraLen = buffer.readUInt16LE(localOffset + 28); const dataStart = localOffset + 30 + localNameLen + localExtraLen; const compData = buffer.subarray(dataStart, dataStart + compSize); - const data = method === 0 ? compData : zlib.inflateRawSync(compData); + // maxOutputLength : filet de sécurité si l'en-tête ment sur uncompSize — + // voir commentaire au-dessus des constantes MAX_ZIP_*. + const data = method === 0 ? compData : zlib.inflateRawSync(compData, { maxOutputLength: MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES }); entries.push({ name, data: Buffer.from(data) }); pos += 46 + nameLen + extraLen + commentLen; diff --git a/frontend/src/components/InvestissementFormModal.jsx b/frontend/src/components/InvestissementFormModal.jsx index a3ff986..47f5c57 100644 --- a/frontend/src/components/InvestissementFormModal.jsx +++ b/frontend/src/components/InvestissementFormModal.jsx @@ -270,7 +270,7 @@ export default function InvestissementFormModal({ ) : ( - Supprimer définitivement ? + Supprimer définitivement (documents inclus) ? diff --git a/frontend/src/pages/InvestissementDetail.jsx b/frontend/src/pages/InvestissementDetail.jsx index b1d6c6a..72c9230 100644 --- a/frontend/src/pages/InvestissementDetail.jsx +++ b/frontend/src/pages/InvestissementDetail.jsx @@ -9,7 +9,7 @@ import RembFormModal from '../components/RembFormModal.jsx'; import { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx'; import InvestissementFormModal from '../components/InvestissementFormModal.jsx'; import { dayOfMonth } from '../utils/dateEcheances.js'; -import { fmtEUR, fmtPct, fmtDate, today } from '../utils/format.js'; +import { fmtEUR, fmtPct, fmtDate, today, fmtOctets } from '../utils/format.js'; import { xirr } from '../utils/xirr.js'; const emptyForm = { @@ -155,24 +155,121 @@ export default function InvestissementDetail() { const [bulkRembDone, setBulkRembDone] = useState(false); const [reinvTab, setReinvTab] = useState('manuel'); // 'manuel' | 'auto' + // Bloc "Mes documents" (cf. load() pour le chargement initial, handlers plus bas) + const [documents, setDocuments] = useState([]); + const [docsMenu, setDocsMenu] = useState(null); + const [docsErr, setDocsErr] = useState(null); + const [uploadingDoc, setUploadingDoc] = useState(false); + const [renamingDocId, setRenamingDocId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const docFileInputRef = useRef(null); + const load = async () => { setLoading(true); try { - const [data, p, comptes, catInv, sectInv] = await Promise.all([ + const [data, p, comptes, catInv, sectInv, docs] = await Promise.all([ api.get(`/investissements/${id}`), api.get('/plateformes'), api.get('/investissements/comptes-courants'), api.get('/categories-inv'), api.get('/secteurs-inv'), + api.get('/documents', { entity_type: 'investissement', entity_id: id }), ]); setInv(data); setPlats(p); setComptesCourants(comptes); setCategoriesInv(catInv); setSecteursInv(sectInv); + setDocuments(docs); } finally { setLoading(false); } }; + /** Recharge uniquement la liste des documents (après upload/renommage/suppression) — + * plus léger qu'un load() complet qui recharge aussi l'investissement, les plateformes, + * les comptes courants et les catégories/secteurs. */ + const refreshDocuments = async () => { + try { + const docs = await api.get('/documents', { entity_type: 'investissement', entity_id: id }); + setDocuments(docs); + } catch { /* la fiche reste utilisable même si le rafraîchissement échoue */ } + }; + + const handleDocFileChange = async (e) => { + const file = e.target.files?.[0]; + e.target.value = ''; // permet de re-sélectionner le même fichier ensuite + if (!file) return; + setUploadingDoc(true); + setDocsErr(null); + try { + const fd = new FormData(); + fd.append('file', file); + fd.append('entity_type', 'investissement'); + fd.append('entity_id', id); + // Nom par défaut proposé : nom du fichier sans son extension (demande Olivier), + // modifiable ensuite via le renommage. + fd.append('nom_affichage', file.name.replace(/\.[^./\\]+$/, '')); + await api.postForm('/documents', fd); + await refreshDocuments(); + } catch (err) { + setDocsErr(err.message); + } finally { + setUploadingDoc(false); + } + }; + + const handleDocRenameStart = (doc) => { + setRenamingDocId(doc.id); + setRenameValue(doc.nom_affichage); + }; + + const handleDocRenameCommit = async (docId) => { + const value = renameValue.trim(); + setRenamingDocId(null); + const current = documents.find(d => d.id === docId); + if (!value || !current || value === current.nom_affichage) return; + try { + await api.put(`/documents/${docId}`, { nom_affichage: value }); + await refreshDocuments(); + } catch (err) { + setDocsErr(err.message); + } + }; + + const handleDocDownload = async (doc) => { + try { + const blob = await api.blob(`/documents/${doc.id}/download`); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `${doc.nom_affichage}.${doc.extension}`; a.click(); + URL.revokeObjectURL(url); + } catch (err) { + setDocsErr(err.message); + } + }; + + const handleDocDelete = (doc) => { + setRowDeleteConfirm({ + message: `Supprimer définitivement le document "${doc.nom_affichage}.${doc.extension}" ?`, + onConfirm: async () => { + await api.del(`/documents/${doc.id}`); + setRowDeleteConfirm(null); + await refreshDocuments(); + }, + }); + }; + + const handleDocDeleteAll = () => { + setDocsMenu(null); + setRowDeleteConfirm({ + message: `Supprimer définitivement les ${documents.length} document(s) de cet investissement ? Cette action est irréversible.`, + onConfirm: async () => { + await api.del(`/documents/by-entity/investissement/${id}`); + setRowDeleteConfirm(null); + await refreshDocuments(); + }, + }); + }; + useEffect(() => { if (activeId) load(); /* eslint-disable-next-line */ }, [id, activeId]); useEffect(() => { api.get('/pfu').then(setPfuRates).catch(() => {}); }, []); @@ -184,6 +281,7 @@ export default function InvestissementDetail() { setRembMenu(null); setReinvMenu(null); setSimulMenu(null); + setDocsMenu(null); }; window.addEventListener('scroll', closeAll, true); return () => window.removeEventListener('scroll', closeAll, true); @@ -851,87 +949,25 @@ export default function InvestissementDetail() { return []; }; - // ── Export dossier JSON ──────────────────────────────────────── - const exportDossier = () => { - const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - const name = `Dossier_Investissement_${inv.id}_${ts}.json`; - const payload = { - version: '1.0', - type: 'dossier_investissement', - exported_at: new Date().toISOString(), - investissement: { - nom_projet: inv.nom_projet, - emetteur: inv.emetteur, - date_souscription: inv.date_souscription, - date_premiere_echeance: inv.date_premiere_echeance, - date_cible: inv.date_cible, - date_debut_simul: inv.date_debut_simul, - montant_investi: inv.montant_investi, - taux_interet: inv.taux_interet, - duree_mois: inv.duree_mois, - type_remb: inv.type_remb, - freq_interets: inv.freq_interets, - statut: inv.statut, - reference: inv.reference, - source: inv.source, - notes: inv.notes, - }, - plateforme: { - nom: inv.plateforme_nom, - }, - remboursements: (inv.remboursements || []).map(r => ({ - date_remb: r.date_remb, - capital: r.capital, - cashback: r.cashback, - interets_bruts: r.interets_bruts, - prelev_sociaux: r.prelev_sociaux, - prelev_forfaitaire: r.prelev_forfaitaire, - interets_nets: r.interets_nets, - net_recu: r.net_recu, - statut: r.statut, - notes: r.notes, - })), - projections: (inv.simul || []).map(s => ({ - numero_echeance: s.numero_echeance, - date_prevue: s.date_prevue, - capital_prevu: s.capital_prevu, - interets_prevus: s.interets_prevus, - total_prevu: s.total_prevu, - })), - reinvestissements: (inv.reinvestissements || []).map(r => ({ - date_reinvestissement: r.date_reinvestissement, - montant: r.montant, - source: r.source, - note: r.note, - })), - historique: (inv.historique || []).map(h => ({ - type_evenement: h.type_evenement, - changements: h.changements, - notes: h.notes, - created_at: h.created_at, - })), - revisions: (inv.revisions || []).map(r => ({ - date_effet: r.date_effet, - ancien_taux: r.ancien_taux, - nouveau_taux: r.nouveau_taux, - ancienne_date_cible: r.ancienne_date_cible, - nouvelle_date_cible: r.nouvelle_date_cible, - motif: r.motif, - created_at: r.created_at, - })), - pertes: (inv.pertes || []).map(p => ({ - date_effet: p.date_effet, - montant_perte: p.montant_perte, - ancien_statut: p.ancien_statut, - motif: p.motif, - created_at: p.created_at, - })), - }; - const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; a.download = name; a.click(); - URL.revokeObjectURL(url); + // ── Export dossier ZIP (manifest.json + documents) ────────────── + // Rework du 29/08/26 (demande Olivier) : ce bouton produisait jusqu'ici un + // simple .json construit ici même à partir des données déjà en mémoire. + // Il appelle maintenant le backend (GET /investissements/:id/export), qui + // construit un ZIP avec un manifest.json consolidant toutes les données du + // dossier (utile pour une future réimportation) et les documents joints + // (bloc "Mes documents" plus bas) — des données que le frontend seul + // n'a pas (le contenu binaire des fichiers). + const exportDossier = async () => { + try { + const blob = await api.blob(`/investissements/${id}/export`); + const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `Dossier_Investissement_${id}_${ts}.zip`; a.click(); + URL.revokeObjectURL(url); + } catch (e) { + window.alert(`Échec de l'export du dossier : ${e.message}`); + } }; // ── Déclaration de perte définitive ────────────────────────────── @@ -1879,6 +1915,88 @@ export default function InvestissementDetail() { )} + {/* ── Mes documents ──────────────────────────────────────────── */} +
+
+

Mes documents

+ +
+ + {docsErr && ( +
+ {docsErr} +
+ )} + + {uploadingDoc && ( +
Envoi du document…
+ )} + + {documents.length === 0 ? ( +
+ Aucun document pour l'instant. Utilisez le menu ⋮ ci-dessus pour en ajouter un. +
+ ) : ( +
+ {documents.map(doc => ( +
+ + + +
+ {renamingDocId === doc.id ? ( + setRenameValue(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') handleDocRenameCommit(doc.id); + if (e.key === 'Escape') setRenamingDocId(null); + }} + onBlur={() => handleDocRenameCommit(doc.id)} + style={{ width: '100%', fontSize: 'var(--fs-sm)' }} + /> + ) : ( +
handleDocRenameStart(doc)} + title="Cliquer pour renommer" + style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} + > + {doc.nom_affichage}.{doc.extension} +
+ )} +
+ {fmtOctets(doc.taille_octets)} · {fmtDate(doc.created_at)} +
+
+ + + +
+ ))} +
+ )} + + +
+ {/* ── Historique des modifications ──────────────────────────── */} {historique.length > 0 && (
@@ -2727,6 +2845,40 @@ export default function InvestissementDetail() { )} + {/* ── Menu ⋮ bloc "Mes documents" ── */} + {docsMenu && ( + <> +
setDocsMenu(null)} /> +
+ + {documents.length > 0 && ( + + )} +
+ + )} +