Implementation de la feature de gestion des fichiers sur les investissements

This commit is contained in:
ocroguennec committed 2026-08-29 18:49:01 +02:00
1 parent f6829dec9d
commit 8953d0bb35
21 files changed
+1729 -181

No files matched your search

+42 -6
View File
@@ -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)