Implementation de la feature de gestion des fichiers sur les investissements
This commit is contained in:
1 parent
f6829dec9d
commit
8953d0bb35
21 files changed
+1729
-181
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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 : <Catégorie>/<Projet>/<fichier> pour les documents liés à un
|
||||
investissement, <Catégorie>/<fichier> 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/<filename>`, 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;
|
||||
@@ -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 });
|
||||
|
||||
+226
-18
@@ -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) => {
|
||||
|
||||
@@ -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/<nom>.<ext> : 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 = ?)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user