import { Router } from 'express'; import { z } from 'zod'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; import sharp from 'sharp'; import db from '../db/index.js'; import { createZip, readZip } from '../utils/zip.js'; import { HttpError } from '../middleware/errorHandler.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const logosDir = path.resolve(__dirname, '../../../data/logos'); fs.mkdirSync(logosDir, { recursive: true }); // ── Traitement image (réutilisé depuis plateformes.js) ───────────────────── async function processLogoFile(filePath) { const ext = path.extname(filePath).toLowerCase(); if (ext === '.svg') { try { let svg = fs.readFileSync(filePath, 'utf8'); // Supprimer fond blanc SVG (background-color, rect blanc, etc.) svg = svg.replace(/(]*)\sstyle="([^"]*)"/i, (_, tag, style) => { const cleaned = style.split(';').filter(s => !/^\s*background(-color)?\s*:/i.test(s)).join(';').replace(/^;+|;+$/g, ''); return cleaned ? `${tag} style="${cleaned}"` : tag; }); svg = svg.replace(/\s+enable-background="[^"]*"/gi, ''); fs.writeFileSync(filePath, svg, 'utf8'); } catch (e) { console.warn('[ref-logos] SVG cleanup failed:', e.message); } return filePath; } if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) { const pngPath = filePath.replace(/\.(jpg|jpeg|webp|png)$/i, '.png'); try { const { data, info } = await sharp(filePath).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); for (let i = 0; i < data.length; i += 4) { if (data[i] > 240 && data[i + 1] > 240 && data[i + 2] > 240) data[i + 3] = 0; } await sharp(data, { raw: { width: info.width, height: info.height, channels: 4 } }) .png({ compressionLevel: 8 }).toFile(pngPath); if (pngPath !== filePath) fs.unlinkSync(filePath); return pngPath; } catch (e) { console.warn('[ref-logos] sharp failed:', e.message); return filePath; } } return filePath; } // ── Multer ────────────────────────────────────────────────────────────────── const storage = multer.diskStorage({ destination: (_req, _file, cb) => cb(null, logosDir), filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() || '.png'; cb(null, `ref_${req.params.id}_${Date.now()}${ext}`); }, }); const upload = multer({ storage, limits: { fileSize: 2 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { const allowed = ['.svg', '.png', '.jpg', '.jpeg', '.webp']; if (allowed.includes(path.extname(file.originalname).toLowerCase())) cb(null, true); else cb(new Error('Format non supporté — SVG, PNG, JPG ou WebP uniquement')); }, }); const router = Router(); // Toutes ces routes sont montées sous requireAdmin dans server.js const Schema = z.object({ nom: z.string().min(1).max(200), url: z.string().url().optional().or(z.literal('')).nullable(), domiciliation: z.string().min(1).max(100).default('france'), fiscalite: z.enum(['flat_tax', 'sans_fiscalite_locale', 'avec_fiscalite_locale']).default('flat_tax'), taux_fiscalite_locale: z.number().min(0).max(100).nullable().optional(), type_produit_fiscal: z.enum(['2TT', '2TR']).default('2TT'), methode_remboursement: z.enum(['portefeuille', 'compte_courant', 'choix_investisseur']).optional().default('portefeuille'), type_pret_defaut: z.enum(['in_fine', 'amortissable', 'differe']).nullable().optional(), freq_interets_defaut: z.enum(['mensuel', 'trimestriel', 'in_fine']).nullable().optional(), logo_filename: z.string().nullable().optional(), description: z.string().nullable().optional(), // Profil enrichi annee_creation: z.number().int().nullable().optional(), investisseurs_types: z.string().nullable().optional(), regulateur: z.string().nullable().optional(), numero_licence: z.string().nullable().optional(), is_regule: z.union([z.boolean(), z.number()]).optional().default(false), pays_inscription: z.string().nullable().optional(), pays_siege: z.string().nullable().optional(), pays_operation: z.array(z.string()).optional().default([]), investissement_minimum: z.number().nullable().optional(), rendement_annonce: z.number().nullable().optional(), nb_investisseurs: z.number().int().nullable().optional(), volume_total_finance: z.number().nullable().optional(), duree_moyenne_pret: z.number().nullable().optional(), garantie_rachat: z.union([z.boolean(), z.number()]).optional().default(false), statistiques_publiques: z.union([z.boolean(), z.number()]).optional().default(false), bonus_inscription: z.union([z.boolean(), z.number()]).optional().default(false), marche_secondaire: z.union([z.boolean(), z.number()]).optional().default(false), investissement_auto: z.union([z.boolean(), z.number()]).optional().default(false), url_trustpilot: z.string().url().optional().or(z.literal('')).nullable(), url_linkedin: z.string().url().optional().or(z.literal('')).nullable(), // Catégories : tableau de noms (strings) — indépendant des catégories user (legacy) categories: z.array(z.string().min(1)).optional().default([]), // Nouvelles listes gérées globalement (IDs) categories_inv_ids: z.array(z.number().int()).optional().default([]), secteurs_inv_ids: z.array(z.number().int()).optional().default([]), // Notation : tableau de critères notation: z.array(z.object({ nom: z.string().min(1), type: z.enum(['etoiles', 'lettres', 'score', 'custom']).default('etoiles'), valeurs: z.array(z.string()).nullable().optional(), min_val: z.number().nullable().optional(), max_val: z.number().nullable().optional(), description: z.string().nullable().optional(), ordre: z.number().int().default(0), })).optional().default([]), }); // ── Helpers ──────────────────────────────────────────────────────────────── /** Catégories attachées au référentiel (stockées comme strings dans referentiel_categories.nom) */ function attachCats(rows) { if (rows.length === 0) return rows; const ids = rows.map(r => r.id); const cats = db.prepare(` SELECT rc.referentiel_id, rc.categorie_nom FROM referentiel_categories rc WHERE rc.referentiel_id IN (${ids.map(() => '?').join(',')}) ORDER BY rc.categorie_nom `).all(...ids); const map = {}; for (const c of cats) { if (!map[c.referentiel_id]) map[c.referentiel_id] = []; map[c.referentiel_id].push(c.categorie_nom); } return rows.map(r => ({ ...r, categories: map[r.id] || [] })); } /** Catégories d'investissement globales (categories_inv) */ function attachCatsInv(rows) { if (rows.length === 0) return rows; const ids = rows.map(r => r.id); const links = db.prepare(` SELECT rc.referentiel_id, c.id AS categorie_id, c.nom FROM referentiel_categories_inv rc JOIN categories_inv c ON c.id = rc.categorie_id WHERE rc.referentiel_id IN (${ids.map(() => '?').join(',')}) ORDER BY c.nom `).all(...ids); const map = {}; for (const l of links) { if (!map[l.referentiel_id]) map[l.referentiel_id] = []; map[l.referentiel_id].push({ id: l.categorie_id, nom: l.nom }); } return rows.map(r => ({ ...r, categories_inv: map[r.id] || [] })); } /** Secteurs d'investissement globaux (secteurs_inv) */ function attachSecteursInv(rows) { if (rows.length === 0) return rows; const ids = rows.map(r => r.id); const links = db.prepare(` SELECT rs.referentiel_id, s.id AS secteur_id, s.nom FROM referentiel_secteurs_inv rs JOIN secteurs_inv s ON s.id = rs.secteur_id WHERE rs.referentiel_id IN (${ids.map(() => '?').join(',')}) ORDER BY s.nom `).all(...ids); const map = {}; for (const l of links) { if (!map[l.referentiel_id]) map[l.referentiel_id] = []; map[l.referentiel_id].push({ id: l.secteur_id, nom: l.nom }); } return rows.map(r => ({ ...r, secteurs_inv: map[r.id] || [] })); } /** Critères de notation du référentiel */ function attachNotation(rows) { if (rows.length === 0) return rows; const ids = rows.map(r => r.id); const notations = db.prepare(` SELECT * FROM referentiel_notation WHERE referentiel_id IN (${ids.map(() => '?').join(',')}) ORDER BY referentiel_id, ordre, id `).all(...ids); const map = {}; for (const n of notations) { if (!map[n.referentiel_id]) map[n.referentiel_id] = []; map[n.referentiel_id].push({ ...n, valeurs: n.valeurs ? JSON.parse(n.valeurs) : null, }); } return rows.map(r => ({ ...r, notation: map[r.id] || [] })); } /** Parse pays_operation (JSON TEXT → array) */ function parsePaysOp(rows) { return rows.map(r => ({ ...r, pays_operation: r.pays_operation ? (typeof r.pays_operation === 'string' ? JSON.parse(r.pays_operation) : r.pays_operation) : [], })); } /** Enregistre catégories, secteurs et notation pour un référentiel */ function saveRelations(tx, refId, categories, notation, categoriesInvIds, secteursInvIds) { tx(() => { // Legacy string categories db.prepare('DELETE FROM referentiel_categories WHERE referentiel_id = ?').run(refId); if (categories.length > 0) { const ins = db.prepare( 'INSERT OR IGNORE INTO referentiel_categories (referentiel_id, categorie_nom) VALUES (?, ?)' ); for (const nom of categories) ins.run(refId, nom.trim()); } // Nouvelles catégories globales (IDs) db.prepare('DELETE FROM referentiel_categories_inv WHERE referentiel_id = ?').run(refId); if (categoriesInvIds && categoriesInvIds.length > 0) { const ins = db.prepare('INSERT OR IGNORE INTO referentiel_categories_inv (referentiel_id, categorie_id) VALUES (?,?)'); for (const id of categoriesInvIds) ins.run(refId, id); } // Secteurs globaux (IDs) db.prepare('DELETE FROM referentiel_secteurs_inv WHERE referentiel_id = ?').run(refId); if (secteursInvIds && secteursInvIds.length > 0) { const ins = db.prepare('INSERT OR IGNORE INTO referentiel_secteurs_inv (referentiel_id, secteur_id) VALUES (?,?)'); for (const id of secteursInvIds) ins.run(refId, id); } db.prepare('DELETE FROM referentiel_notation WHERE referentiel_id = ?').run(refId); if (notation.length > 0) { const ins = db.prepare(` INSERT INTO referentiel_notation (referentiel_id, nom, type, valeurs, min_val, max_val, description, ordre) VALUES (?,?,?,?,?,?,?,?) `); for (const n of notation) { ins.run( refId, n.nom, n.type || 'etoiles', (n.valeurs && n.valeurs.length) ? JSON.stringify(n.valeurs) : null, n.min_val ?? null, n.max_val ?? null, n.description ?? null, n.ordre ?? 0 ); } } }); } // ── GET /api/referentiel ─────────────────────────────────────────────────── router.get('/', (_req, res) => { let rows = db.prepare(` SELECT pr.*, (SELECT COUNT(*) FROM plateformes p WHERE p.referentiel_id = pr.id) AS nb_plateformes_liees FROM plateformes_referentiel pr ORDER BY pr.nom `).all(); rows = attachCats(rows); rows = attachCatsInv(rows); rows = attachSecteursInv(rows); rows = attachNotation(rows); rows = parsePaysOp(rows); res.json(rows); }); // ── Multer for ZIP uploads ───────────────────────────────────────────────── const zipUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { if (file.mimetype === 'application/zip' || file.originalname.endsWith('.zip')) cb(null, true); else cb(new HttpError(400, 'Fichier ZIP attendu')); }, }); // ── GET /api/referentiel/export — export tout le référentiel ─────────────── router.get('/export', (_req, res, next) => { try { let rows = db.prepare('SELECT * FROM plateformes_referentiel ORDER BY nom').all(); rows = attachCatsInv(rows); rows = attachSecteursInv(rows); rows = parsePaysOp(rows); const entries = []; const manifest = { version: '1.0', app: 'crowdlending', exported_at: new Date().toISOString(), count: rows.length, type: 'referentiel', }; entries.push({ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) }); const dataRows = rows.map(r => ({ nom: r.nom, url: r.url, domiciliation: r.domiciliation, fiscalite: r.fiscalite, taux_fiscalite_locale: r.taux_fiscalite_locale, type_produit_fiscal: r.type_produit_fiscal, description: r.description, annee_creation: r.annee_creation, investisseurs_types: r.investisseurs_types, regulateur: r.regulateur, numero_licence: r.numero_licence, is_regule: r.is_regule, pays_inscription: r.pays_inscription, pays_siege: r.pays_siege, pays_operation: r.pays_operation || [], investissement_minimum: r.investissement_minimum, rendement_annonce: r.rendement_annonce, nb_investisseurs: r.nb_investisseurs, volume_total_finance: r.volume_total_finance, duree_moyenne_pret: r.duree_moyenne_pret, garantie_rachat: r.garantie_rachat, statistiques_publiques: r.statistiques_publiques, bonus_inscription: r.bonus_inscription, marche_secondaire: r.marche_secondaire, investissement_auto: r.investissement_auto, url_trustpilot: r.url_trustpilot, url_linkedin: r.url_linkedin, methode_remboursement: r.methode_remboursement, type_pret_defaut: r.type_pret_defaut, freq_interets_defaut: r.freq_interets_defaut, logo_filename: r.logo_filename, icone_filename: r.icone_filename, categories_inv: (r.categories_inv || []).map(c => c.nom), secteurs_inv: (r.secteurs_inv || []).map(s => s.nom), })); entries.push({ name: 'data.json', data: JSON.stringify(dataRows, null, 2) }); // Include logo / icon files for (const r of rows) { for (const fname of [r.logo_filename, r.icone_filename]) { if (!fname) continue; const fpath = path.join(logosDir, fname); if (fs.existsSync(fpath)) { entries.push({ name: `logos/${fname}`, data: fs.readFileSync(fpath) }); } } } const zipBuf = createZip(entries); const slug = 'referentiel-' + new Date().toISOString().slice(0, 10); res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename="${slug}.zip"`); res.send(zipBuf); } catch (e) { next(e); } }); // ── GET /api/referentiel/:id/export — export une seule entrée ─────────────── router.get('/:id/export', (req, res, next) => { try { const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); if (!ref) throw new HttpError(404, 'Référentiel introuvable'); const [enriched] = parsePaysOp(attachSecteursInv(attachCatsInv([ref]))); const entries = []; const manifest = { version: '1.0', app: 'crowdlending', exported_at: new Date().toISOString(), count: 1, type: 'referentiel', }; entries.push({ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) }); const dataRow = { nom: enriched.nom, url: enriched.url, domiciliation: enriched.domiciliation, fiscalite: enriched.fiscalite, taux_fiscalite_locale: enriched.taux_fiscalite_locale, type_produit_fiscal: enriched.type_produit_fiscal, description: enriched.description, annee_creation: enriched.annee_creation, investisseurs_types: enriched.investisseurs_types, regulateur: enriched.regulateur, numero_licence: enriched.numero_licence, is_regule: enriched.is_regule, pays_inscription: enriched.pays_inscription, pays_siege: enriched.pays_siege, pays_operation: enriched.pays_operation || [], investissement_minimum: enriched.investissement_minimum, rendement_annonce: enriched.rendement_annonce, nb_investisseurs: enriched.nb_investisseurs, volume_total_finance: enriched.volume_total_finance, duree_moyenne_pret: enriched.duree_moyenne_pret, garantie_rachat: enriched.garantie_rachat, statistiques_publiques: enriched.statistiques_publiques, bonus_inscription: enriched.bonus_inscription, marche_secondaire: enriched.marche_secondaire, investissement_auto: enriched.investissement_auto, url_trustpilot: enriched.url_trustpilot, url_linkedin: enriched.url_linkedin, methode_remboursement: enriched.methode_remboursement, type_pret_defaut: enriched.type_pret_defaut, freq_interets_defaut: enriched.freq_interets_defaut, logo_filename: enriched.logo_filename, icone_filename: enriched.icone_filename, categories_inv: (enriched.categories_inv || []).map(c => c.nom), secteurs_inv: (enriched.secteurs_inv || []).map(s => s.nom), }; entries.push({ name: 'data.json', data: JSON.stringify([dataRow], null, 2) }); for (const fname of [enriched.logo_filename, enriched.icone_filename]) { if (!fname) continue; const fpath = path.join(logosDir, fname); if (fs.existsSync(fpath)) { entries.push({ name: `logos/${fname}`, data: fs.readFileSync(fpath) }); } } const zipBuf = createZip(entries); const slug = enriched.nom.toLowerCase().replace(/[^a-z0-9]+/g, '-') + '-' + new Date().toISOString().slice(0, 10); res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename="${slug}.zip"`); res.send(zipBuf); } catch (e) { next(e); } }); // ── POST /api/referentiel/import-zip — import depuis un fichier ZIP ───────── router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => { try { if (!req.file) throw new HttpError(400, 'Fichier ZIP manquant'); const zipEntries = readZip(req.file.buffer); const manifestEntry = zipEntries.find(e => e.name === 'manifest.json'); const dataEntry = zipEntries.find(e => e.name === 'data.json'); if (!dataEntry) throw new HttpError(400, 'ZIP invalide : data.json manquant'); const platforms = JSON.parse(dataEntry.data.toString('utf8')); if (!Array.isArray(platforms)) throw new HttpError(400, 'data.json doit être un tableau'); // Normalise tag names to IDs (create global if missing) function resolveTagIds(names, table) { const ids = []; for (const nom of (names || [])) { const trimmed = nom.trim(); if (!trimmed) continue; let row = db.prepare(`SELECT id FROM ${table} WHERE nom = ? AND user_id IS NULL`).get(trimmed); if (!row) { const r = db.prepare(`INSERT INTO ${table} (nom, user_id) VALUES (?, NULL)`).run(trimmed); row = { id: r.lastInsertRowid }; } ids.push(row.id); } return ids; } // Build a map of images from the ZIP const imageMap = {}; for (const e of zipEntries) { if (e.name.startsWith('logos/') && e.name.length > 6) { imageMap[path.basename(e.name)] = e.data; } } let created = 0; let updated = 0; const tx = db.transaction(() => { for (const p of platforms) { if (!p.nom) continue; const catsIds = resolveTagIds(p.categories_inv, 'categories_inv'); const sectsIds = resolveTagIds(p.secteurs_inv, 'secteurs_inv'); const paysOp = Array.isArray(p.pays_operation) && p.pays_operation.length ? JSON.stringify(p.pays_operation) : null; const existing = db.prepare('SELECT id FROM plateformes_referentiel WHERE nom = ?').get(p.nom); const fields = [ p.url || null, p.domiciliation || 'france', p.fiscalite || 'flat_tax', p.taux_fiscalite_locale ?? null, p.type_produit_fiscal || '2TT', p.description ?? null, p.annee_creation ?? null, p.investisseurs_types ?? null, p.regulateur ?? null, p.numero_licence ?? null, p.is_regule ? 1 : 0, p.pays_inscription ?? null, p.pays_siege ?? null, paysOp, p.investissement_minimum ?? null, p.rendement_annonce ?? null, p.nb_investisseurs ?? null, p.volume_total_finance ?? null, p.duree_moyenne_pret ?? null, p.garantie_rachat ? 1 : 0, p.statistiques_publiques ? 1 : 0, p.bonus_inscription ? 1 : 0, p.marche_secondaire ? 1 : 0, p.investissement_auto ? 1 : 0, p.url_trustpilot || null, p.url_linkedin || null, p.logo_filename ?? null, p.icone_filename ?? null, ]; let refId; if (existing) { db.prepare(` UPDATE plateformes_referentiel SET url=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, type_produit_fiscal=?, description=?, annee_creation=?, investisseurs_types=?, regulateur=?, numero_licence=?, is_regule=?, pays_inscription=?, pays_siege=?, pays_operation=?, investissement_minimum=?, rendement_annonce=?, nb_investisseurs=?, volume_total_finance=?, duree_moyenne_pret=?, garantie_rachat=?, statistiques_publiques=?, bonus_inscription=?, marche_secondaire=?, investissement_auto=?, url_trustpilot=?, url_linkedin=?, logo_filename=?, icone_filename=?, updated_at=datetime('now') WHERE id=? `).run(...fields, existing.id); refId = existing.id; updated++; } else { const r = db.prepare(` INSERT INTO plateformes_referentiel (nom, url, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, description, annee_creation, investisseurs_types, regulateur, numero_licence, is_regule, pays_inscription, pays_siege, pays_operation, investissement_minimum, rendement_annonce, nb_investisseurs, volume_total_finance, duree_moyenne_pret, garantie_rachat, statistiques_publiques, bonus_inscription, marche_secondaire, investissement_auto, url_trustpilot, url_linkedin, logo_filename, icone_filename, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now')) `).run(p.nom, ...fields); refId = r.lastInsertRowid; created++; } // Sync categories_inv db.prepare('DELETE FROM referentiel_categories_inv WHERE referentiel_id = ?').run(refId); for (const id of catsIds) { db.prepare('INSERT OR IGNORE INTO referentiel_categories_inv (referentiel_id, categorie_id) VALUES (?,?)').run(refId, id); } // Sync secteurs_inv db.prepare('DELETE FROM referentiel_secteurs_inv WHERE referentiel_id = ?').run(refId); for (const id of sectsIds) { db.prepare('INSERT OR IGNORE INTO referentiel_secteurs_inv (referentiel_id, secteur_id) VALUES (?,?)').run(refId, id); } // Write logo / icon images from ZIP for (const fname of [p.logo_filename, p.icone_filename]) { if (!fname || !imageMap[fname]) continue; const dest = path.join(logosDir, fname); fs.writeFileSync(dest, imageMap[fname]); } } }); tx(); res.json({ ok: true, created, updated, total: platforms.length }); } catch (e) { next(e); } }); // ── GET /api/referentiel/:id ─────────────────────────────────────────────── router.get('/:id', (req, res, next) => { try { const row = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); if (!row) throw new HttpError(404, 'Référentiel introuvable'); const [enriched] = parsePaysOp(attachNotation(attachSecteursInv(attachCatsInv(attachCats([row]))))); res.json(enriched); } catch (e) { next(e); } }); // ── POST /api/referentiel ────────────────────────────────────────────────── router.post('/', (req, res, next) => { try { const data = Schema.parse(req.body); const existing = db.prepare('SELECT id FROM plateformes_referentiel WHERE nom = ?').get(data.nom); if (existing) throw new HttpError(409, `Une entrée référentiel "${data.nom}" existe déjà`); const r = db.prepare(` INSERT INTO plateformes_referentiel (nom, url, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, methode_remboursement, type_pret_defaut, freq_interets_defaut, logo_filename, description, annee_creation, investisseurs_types, regulateur, numero_licence, is_regule, pays_inscription, pays_siege, pays_operation, investissement_minimum, rendement_annonce, nb_investisseurs, volume_total_finance, duree_moyenne_pret, garantie_rachat, statistiques_publiques, bonus_inscription, marche_secondaire, investissement_auto, url_trustpilot, url_linkedin, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now')) `).run( data.nom, data.url || null, data.domiciliation, data.fiscalite, data.taux_fiscalite_locale ?? null, data.type_produit_fiscal, data.methode_remboursement || 'portefeuille', data.type_pret_defaut ?? null, data.freq_interets_defaut ?? null, data.logo_filename ?? null, data.description ?? null, data.annee_creation ?? null, data.investisseurs_types ?? null, data.regulateur ?? null, data.numero_licence ?? null, data.is_regule ? 1 : 0, data.pays_inscription ?? null, data.pays_siege ?? null, data.pays_operation?.length ? JSON.stringify(data.pays_operation) : null, data.investissement_minimum ?? null, data.rendement_annonce ?? null, data.nb_investisseurs ?? null, data.volume_total_finance ?? null, data.duree_moyenne_pret ?? null, data.garantie_rachat ? 1 : 0, data.statistiques_publiques ? 1 : 0, data.bonus_inscription ? 1 : 0, data.marche_secondaire ? 1 : 0, data.investissement_auto ? 1 : 0, data.url_trustpilot || null, data.url_linkedin || null ); const refId = r.lastInsertRowid; const tx = db.transaction(fn => fn()); saveRelations(tx, refId, data.categories, data.notation, data.categories_inv_ids, data.secteurs_inv_ids); const row = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(refId); const [enriched] = parsePaysOp(attachNotation(attachSecteursInv(attachCatsInv(attachCats([row]))))); enriched.nb_plateformes_liees = 0; res.status(201).json(enriched); } catch (e) { next(e); } }); // ── PUT /api/referentiel/:id ─────────────────────────────────────────────── router.put('/:id', (req, res, next) => { try { const row = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); if (!row) throw new HttpError(404, 'Référentiel introuvable'); const data = Schema.parse(req.body); // Vérifier unicité du nom si changé if (data.nom !== row.nom) { const dup = db.prepare('SELECT id FROM plateformes_referentiel WHERE nom = ? AND id != ?').get(data.nom, row.id); if (dup) throw new HttpError(409, `Une entrée référentiel "${data.nom}" existe déjà`); } db.prepare(` UPDATE plateformes_referentiel SET nom=?, url=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, type_produit_fiscal=?, methode_remboursement=?, type_pret_defaut=?, freq_interets_defaut=?, logo_filename=?, description=?, annee_creation=?, investisseurs_types=?, regulateur=?, numero_licence=?, is_regule=?, pays_inscription=?, pays_siege=?, pays_operation=?, investissement_minimum=?, rendement_annonce=?, nb_investisseurs=?, volume_total_finance=?, duree_moyenne_pret=?, garantie_rachat=?, statistiques_publiques=?, bonus_inscription=?, marche_secondaire=?, investissement_auto=?, url_trustpilot=?, url_linkedin=?, updated_at=datetime('now') WHERE id=? `).run( data.nom, data.url || null, data.domiciliation, data.fiscalite, data.taux_fiscalite_locale ?? null, data.type_produit_fiscal, data.methode_remboursement || 'portefeuille', data.type_pret_defaut ?? null, data.freq_interets_defaut ?? null, data.logo_filename ?? null, data.description ?? null, data.annee_creation ?? null, data.investisseurs_types ?? null, data.regulateur ?? null, data.numero_licence ?? null, data.is_regule ? 1 : 0, data.pays_inscription ?? null, data.pays_siege ?? null, data.pays_operation?.length ? JSON.stringify(data.pays_operation) : null, data.investissement_minimum ?? null, data.rendement_annonce ?? null, data.nb_investisseurs ?? null, data.volume_total_finance ?? null, data.duree_moyenne_pret ?? null, data.garantie_rachat ? 1 : 0, data.statistiques_publiques ? 1 : 0, data.bonus_inscription ? 1 : 0, data.marche_secondaire ? 1 : 0, data.investissement_auto ? 1 : 0, data.url_trustpilot || null, data.url_linkedin || null, row.id ); const tx = db.transaction(fn => fn()); saveRelations(tx, row.id, data.categories, data.notation, data.categories_inv_ids, data.secteurs_inv_ids); const updated = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(row.id); const nb = db.prepare('SELECT COUNT(*) AS n FROM plateformes WHERE referentiel_id = ?').get(row.id).n; const [enriched] = parsePaysOp(attachNotation(attachSecteursInv(attachCatsInv(attachCats([updated]))))); enriched.nb_plateformes_liees = nb; res.json(enriched); } catch (e) { next(e); } }); // ── DELETE /api/referentiel/:id ──────────────────────────────────────────── // Délie les plateformes liées (referentiel_id → null) avant suppression router.delete('/:id', (req, res, next) => { try { const row = db.prepare('SELECT id FROM plateformes_referentiel WHERE id = ?').get(req.params.id); if (!row) throw new HttpError(404, 'Référentiel introuvable'); db.transaction(() => { // Délier les plateformes (on ne supprime pas les données user) db.prepare('UPDATE plateformes SET referentiel_id = NULL WHERE referentiel_id = ?').run(row.id); db.prepare('DELETE FROM plateformes_referentiel WHERE id = ?').run(row.id); })(); res.json({ deleted: true }); } catch (e) { next(e); } }); // ── GET /api/referentiel/:id/plateformes ─────────────────────────────────── // Liste les plateformes user liées à ce référentiel router.get('/:id/plateformes', (req, res, next) => { try { const row = db.prepare('SELECT id FROM plateformes_referentiel WHERE id = ?').get(req.params.id); if (!row) throw new HttpError(404, 'Référentiel introuvable'); const plateformes = db.prepare(` SELECT p.id, p.nom, p.domiciliation, p.fiscalite, p.overridden_fields, u.email AS user_email, inv.nom AS detenteur_nom, inv.prenom AS detenteur_prenom FROM plateformes p JOIN users u ON u.id = p.user_id LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id WHERE p.referentiel_id = ? ORDER BY u.email, p.nom `).all(req.params.id); res.json(plateformes.map(p => ({ ...p, overridden_fields: JSON.parse(p.overridden_fields || '[]'), }))); } catch (e) { next(e); } }); // ── POST /api/referentiel/:id/push ───────────────────────────────────────── // Pousse les champs du référentiel vers les plateformes liées. // Body { force: true } → écrase tous les champs (méthode dure), y compris les overrides utilisateur. // Défaut (force absent ou false) → respecte overridden_fields (méthode douce). router.post('/:id/push', (req, res, next) => { try { const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); if (!ref) throw new HttpError(404, 'Référentiel introuvable'); const force = req.body?.force === true; // Champs scalaires poussables const PUSHABLE = ['nom', 'url', 'domiciliation', 'fiscalite', 'taux_fiscalite_locale', 'type_produit_fiscal', 'logo_filename', 'methode_remboursement', 'type_pret_defaut', 'freq_interets_defaut']; // Catégories/secteurs du référentiel const refCatIds = db.prepare( 'SELECT categorie_id FROM referentiel_categories_inv WHERE referentiel_id = ?' ).all(ref.id).map(r => r.categorie_id); const refSectIds = db.prepare( 'SELECT secteur_id FROM referentiel_secteurs_inv WHERE referentiel_id = ?' ).all(ref.id).map(r => r.secteur_id); const plateformes = db.prepare( 'SELECT id, overridden_fields FROM plateformes WHERE referentiel_id = ?' ).all(ref.id); let nb_updated = 0; db.transaction(() => { const insCat = db.prepare('INSERT OR IGNORE INTO plateforme_categories_inv (plateforme_id, categorie_id) VALUES (?, ?)'); const insSect = db.prepare('INSERT OR IGNORE INTO plateforme_secteurs_inv (plateforme_id, secteur_id) VALUES (?, ?)'); const insInvCat = db.prepare('INSERT OR IGNORE INTO investissement_categories_inv (investissement_id, categorie_id) VALUES (?, ?)'); const insInvSect = db.prepare('INSERT OR IGNORE INTO investissement_secteurs_inv (investissement_id, secteur_id) VALUES (?, ?)'); for (const plat of plateformes) { // ── Champs scalaires ── let fields; if (force) { fields = PUSHABLE; } else { const overridden = JSON.parse(plat.overridden_fields || '[]'); fields = PUSHABLE.filter(f => !overridden.includes(f)); } if (fields.length > 0) { const setParts = fields.map(f => `${f} = ?`).join(', '); const values = fields.map(f => ref[f] ?? null); const extraSet = force ? ', overridden_fields = \'[]\'' : ''; db.prepare(`UPDATE plateformes SET ${setParts}${extraSet} WHERE id = ?`).run(...values, plat.id); } // ── Catégories/secteurs ── if (force) { // Méthode dure : remplace toutes les associations db.prepare('DELETE FROM plateforme_categories_inv WHERE plateforme_id = ?').run(plat.id); db.prepare('DELETE FROM plateforme_secteurs_inv WHERE plateforme_id = ?').run(plat.id); } // Méthode douce : INSERT OR IGNORE (n'écrase pas les associations existantes) for (const catId of refCatIds) insCat.run(plat.id, catId); for (const sectId of refSectIds) insSect.run(plat.id, sectId); // Sync investissements actifs de la plateforme const invs = db.prepare( "SELECT id FROM investissements WHERE plateforme_id = ? AND statut NOT IN ('rembourse','cloture')" ).all(plat.id); for (const inv of invs) { if (force) { db.prepare('DELETE FROM investissement_categories_inv WHERE investissement_id = ?').run(inv.id); db.prepare('DELETE FROM investissement_secteurs_inv WHERE investissement_id = ?').run(inv.id); } for (const catId of refCatIds) insInvCat.run(inv.id, catId); for (const sectId of refSectIds) insInvSect.run(inv.id, sectId); } nb_updated++; } })(); res.json({ pushed: true, force, nb_plateformes: plateformes.length, nb_updated }); } catch (e) { next(e); } }); // ── POST /api/referentiel/:id/logo ───────────────────────────────────────── // Upload d'un logo pour une entrée du référentiel ; auto-push vers les plateformes liées router.post('/:id/logo', upload.single('file'), async (req, res, next) => { try { const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); if (!ref) throw new HttpError(404, 'Référentiel introuvable'); if (!req.file) throw new HttpError(400, 'Fichier requis'); // Traiter l'image (fond blanc transparent, conversion en PNG si besoin) const finalPath = await processLogoFile(req.file.path); const filename = path.basename(finalPath); // Supprimer l'ancien logo s'il existe if (ref.logo_filename) { const old = path.join(logosDir, ref.logo_filename); if (fs.existsSync(old)) fs.unlink(old, () => {}); } // Mettre à jour le référentiel db.prepare(`UPDATE plateformes_referentiel SET logo_filename=?, updated_at=datetime('now') WHERE id=?`) .run(filename, ref.id); // Auto-push logo_filename vers les plateformes liées (sauf si overridé) const plateformes = db.prepare('SELECT id, overridden_fields FROM plateformes WHERE referentiel_id = ?').all(ref.id); db.transaction(() => { for (const plat of plateformes) { const overridden = JSON.parse(plat.overridden_fields || '[]'); if (!overridden.includes('logo_filename')) { db.prepare('UPDATE plateformes SET logo_filename=? WHERE id=?').run(filename, plat.id); } } })(); res.json({ logo_filename: filename, nb_plateformes_updated: plateformes.filter(p => !JSON.parse(p.overridden_fields || '[]').includes('logo_filename')).length }); } catch (e) { next(e); } }); // ── DELETE /api/referentiel/:id/logo ─────────────────────────────────────── router.delete('/:id/logo', async (req, res, next) => { try { const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); if (!ref) throw new HttpError(404, 'Référentiel introuvable'); if (ref.logo_filename) { const filePath = path.join(logosDir, ref.logo_filename); if (fs.existsSync(filePath)) fs.unlink(filePath, () => {}); } db.prepare(`UPDATE plateformes_referentiel SET logo_filename=NULL, updated_at=datetime('now') WHERE id=?`).run(ref.id); // Pousser null vers les plateformes liées non-overridées const plateformes = db.prepare('SELECT id, overridden_fields FROM plateformes WHERE referentiel_id = ?').all(ref.id); db.transaction(() => { for (const plat of plateformes) { const overridden = JSON.parse(plat.overridden_fields || '[]'); if (!overridden.includes('logo_filename')) { db.prepare('UPDATE plateformes SET logo_filename=NULL WHERE id=?').run(plat.id); } } })(); res.json({ deleted: true }); } catch (e) { next(e); } }); // ── POST /api/referentiel/:id/icone ────────────────────────────────────────── // Upload d'une icone pour une entree du referentiel ; auto-push vers les plateformes liees router.post('/:id/icone', upload.single('file'), async (req, res, next) => { try { const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); if (!ref) throw new HttpError(404, 'Referentiel introuvable'); if (!req.file) throw new HttpError(400, 'Fichier requis'); const finalPath = await processLogoFile(req.file.path); const filename = path.basename(finalPath); if (ref.icone_filename) { const oldFile = path.join(logosDir, ref.icone_filename); if (fs.existsSync(oldFile)) fs.unlink(oldFile, () => {}); } db.prepare(`UPDATE plateformes_referentiel SET icone_filename=?, updated_at=datetime('now') WHERE id=?`) .run(filename, ref.id); // Auto-push icone_filename vers les plateformes liées (sauf si overridé) const plateformesIcn = db.prepare('SELECT id, overridden_fields FROM plateformes WHERE referentiel_id = ?').all(ref.id); db.transaction(() => { for (const plat of plateformesIcn) { const overridden = JSON.parse(plat.overridden_fields || '[]'); if (!overridden.includes('icone_filename')) { db.prepare('UPDATE plateformes SET icone_filename=? WHERE id=?').run(filename, plat.id); } } })(); res.json({ icone_filename: filename, nb_plateformes_updated: plateformesIcn.filter(p => !JSON.parse(p.overridden_fields || '[]').includes('icone_filename')).length }); } catch (e) { next(e); } }); // ── DELETE /api/referentiel/:id/icone ───────────────────────────────────────── router.delete('/:id/icone', async (req, res, next) => { try { const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); if (!ref) throw new HttpError(404, 'Referentiel introuvable'); if (ref.icone_filename) { const filePath = path.join(logosDir, ref.icone_filename); if (fs.existsSync(filePath)) fs.unlink(filePath, () => {}); } db.prepare(`UPDATE plateformes_referentiel SET icone_filename=NULL, updated_at=datetime('now') WHERE id=?`).run(ref.id); const plateformesIcn = db.prepare('SELECT id, overridden_fields FROM plateformes WHERE referentiel_id = ?').all(ref.id); db.transaction(() => { for (const plat of plateformesIcn) { const overridden = JSON.parse(plat.overridden_fields || '[]'); if (!overridden.includes('icone_filename')) { db.prepare('UPDATE plateformes SET icone_filename=NULL WHERE id=?').run(plat.id); } } })(); res.json({ deleted: true }); } catch (e) { next(e); } }); // -- NOTATION -- criteres par referentiel ----------------------------------------- const TYPES_VALIDES_NOT = ['etoiles', 'lettres', 'score', 'custom']; function enrichNot(row) { return { ...row, valeurs: row.valeurs ? JSON.parse(row.valeurs) : null }; } // GET /api/referentiel/:id/notation router.get('/:id/notation', (req, res, next) => { try { const ref = db.prepare('SELECT id FROM plateformes_referentiel WHERE id = ?').get(req.params.id); if (!ref) throw new HttpError(404, 'Referentiel introuvable'); const rows = db.prepare( 'SELECT * FROM referentiel_notation WHERE referentiel_id = ? ORDER BY ordre, id' ).all(ref.id); res.json(rows.map(enrichNot)); } catch (e) { next(e); } }); // POST /api/referentiel/:id/notation router.post('/:id/notation', (req, res, next) => { try { const ref = db.prepare('SELECT id FROM plateformes_referentiel WHERE id = ?').get(req.params.id); if (!ref) throw new HttpError(404, 'Referentiel introuvable'); const { nom, type = 'etoiles', valeurs, min_val, max_val, description, ordre } = req.body || {}; if (!nom?.trim()) throw new HttpError(400, 'nom est requis'); if (!TYPES_VALIDES_NOT.includes(type)) throw new HttpError(400, 'type invalide'); const r = db.prepare(` INSERT INTO referentiel_notation (referentiel_id, nom, type, valeurs, min_val, max_val, description, ordre) VALUES (?,?,?,?,?,?,?,?) `).run( ref.id, nom.trim(), type, (type === 'lettres' || type === 'custom') ? JSON.stringify(Array.isArray(valeurs) ? valeurs : String(valeurs || '').split(',').map(v => v.trim()).filter(Boolean)) : null, type === 'score' ? Number(min_val ?? 0) : null, type === 'score' ? Number(max_val ?? 10) : null, description?.trim() || null, Number(ordre ?? 0), ); res.status(201).json(enrichNot(db.prepare('SELECT * FROM referentiel_notation WHERE id = ?').get(r.lastInsertRowid))); } catch (e) { next(e); } }); // PUT /api/referentiel/notation/:notationId router.put('/notation/:notationId', (req, res, next) => { try { const existing = db.prepare('SELECT * FROM referentiel_notation WHERE id = ?').get(req.params.notationId); if (!existing) throw new HttpError(404, 'Critere introuvable'); const { nom, type, valeurs, min_val, max_val, description, ordre } = req.body || {}; const t = type || existing.type; if (!nom?.trim()) throw new HttpError(400, 'nom est requis'); db.prepare(` UPDATE referentiel_notation SET nom=?, type=?, valeurs=?, min_val=?, max_val=?, description=?, ordre=? WHERE id=? `).run( nom.trim(), t, (t === 'lettres' || t === 'custom') ? JSON.stringify(Array.isArray(valeurs) ? valeurs : String(valeurs || '').split(',').map(v => v.trim()).filter(Boolean)) : null, t === 'score' ? Number(min_val ?? 0) : null, t === 'score' ? Number(max_val ?? 10) : null, description?.trim() || null, Number(ordre ?? 0), req.params.notationId, ); res.json(enrichNot(db.prepare('SELECT * FROM referentiel_notation WHERE id = ?').get(req.params.notationId))); } catch (e) { next(e); } }); // DELETE /api/referentiel/notation/:notationId router.delete('/notation/:notationId', (req, res, next) => { try { const existing = db.prepare('SELECT id FROM referentiel_notation WHERE id = ?').get(req.params.notationId); if (!existing) throw new HttpError(404, 'Critere introuvable'); db.prepare('DELETE FROM referentiel_notation WHERE id = ?').run(req.params.notationId); res.json({ deleted: true }); } catch (e) { next(e); } }); export default router;