import { Router } from 'express'; import { z } from 'zod'; import db from '../db/index.js'; import { HttpError } from '../middleware/errorHandler.js'; import { createZip, readZip } from '../utils/zip.js'; import multer from 'multer'; import path from 'node:path'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const logosDir = path.resolve(__dirname, '../../../data/logos'); fs.mkdirSync(logosDir, { recursive: true }); /** Sanitise un nom de plateforme pour en faire un nom de fichier safe */ function sanitizeNom(nom) { return (nom || 'plateforme') .normalize('NFD').replace(/[̀-ͯ]/g, '') .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') || 'plateforme'; } const ALLOWED_MIMES = { 'image/svg+xml': 'svg', 'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg' }; const logoStorage = multer.diskStorage({ destination: (_req, _file, cb) => cb(null, logosDir), filename: (req, file, cb) => { const plat = db.prepare('SELECT nom FROM plateformes WHERE id = ? AND user_id = ?') .get(req.params.id, req.user.id); const ext = ALLOWED_MIMES[file.mimetype] || 'png'; cb(null, `logo_${sanitizeNom(plat?.nom || String(req.params.id))}_${Date.now()}.${ext}`); }, }); const upload = multer({ storage: logoStorage, limits: { fileSize: 2 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { if (ALLOWED_MIMES[file.mimetype]) cb(null, true); else cb(new HttpError(400, 'Format non supporté (SVG, PNG, JPEG uniquement)')); }, }); const router = Router(); const Schema = z.object({ nom: z.string().min(1), url: z.string().url().optional().or(z.literal('')), notes: z.string().optional(), categories: z.array(z.number().int().positive()).optional().default([]), 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']).default('portefeuille'), investisseur_id: z.number().int().positive().nullable().optional(), date_ouverture: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(), type_pret_defaut: z.enum(['in_fine', 'amortissable', 'differe']).nullable().optional(), freq_interets_defaut: z.enum(['mensuel', 'trimestriel', 'in_fine']).nullable().optional(), referentiel_id: z.number().int().positive().nullable().optional(), }); // Champs hérités du référentiel (comparés pour calculer overridden_fields) const HERITABLE_FIELDS = ['nom', 'url', 'domiciliation', 'fiscalite', 'taux_fiscalite_locale', 'type_produit_fiscal', 'logo_filename', 'icone_filename', 'methode_remboursement', 'type_pret_defaut', 'freq_interets_defaut']; /** * Calcule overridden_fields : champs dont la valeur diffère du référentiel. * Retourne le tableau mis à jour des champs overridés. */ function computeOverridden(ref, newValues, currentOverridden = []) { if (!ref) return currentOverridden; const overridden = new Set(currentOverridden); for (const field of HERITABLE_FIELDS) { const refVal = ref[field] ?? null; const newVal = newValues[field] ?? null; // Comparer en string pour éviter les faux positifs number vs null if (String(refVal) !== String(newVal)) { overridden.add(field); } else { overridden.delete(field); } } return [...overridden]; } function attachCategories(userId, rows) { if (rows.length === 0) return rows; const platIds = rows.map(r => r.id); const cats = db.prepare(` SELECT pc.plateforme_id, c.id, c.nom FROM plateforme_categories pc JOIN categories_plateforme c ON c.id = pc.categorie_id WHERE c.user_id = ? AND pc.plateforme_id IN (${platIds.map(() => '?').join(',')}) ORDER BY c.nom `).all(userId, ...platIds); const map = {}; for (const c of cats) { if (!map[c.plateforme_id]) map[c.plateforme_id] = []; map[c.plateforme_id].push({ id: c.id, nom: c.nom }); } return rows.map(r => ({ ...r, categories: map[r.id] || [] })); } function attachCategoriesInv(rows) { if (rows.length === 0) return rows; const ids = rows.map(r => r.id); // Tags propres à chaque plateforme const links = db.prepare(` SELECT pc.plateforme_id, c.id AS categorie_id, c.nom FROM plateforme_categories_inv pc JOIN categories_inv c ON c.id = pc.categorie_id WHERE pc.plateforme_id IN (${ids.map(() => '?').join(',')}) ORDER BY c.nom `).all(...ids); const map = {}; for (const l of links) { if (!map[l.plateforme_id]) map[l.plateforme_id] = []; map[l.plateforme_id].push({ id: l.categorie_id, nom: l.nom, is_inherited: false }); } // Tags du référentiel pour les plateformes liées (héritage par fusion) const refIds = [...new Set(rows.filter(r => r.referentiel_id).map(r => r.referentiel_id))]; const refCatMap = {}; if (refIds.length > 0) { const refLinks = 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 (${refIds.map(() => '?').join(',')}) ORDER BY c.nom `).all(...refIds); for (const l of refLinks) { if (!refCatMap[l.referentiel_id]) refCatMap[l.referentiel_id] = []; refCatMap[l.referentiel_id].push({ id: l.categorie_id, nom: l.nom }); } } return rows.map(r => { const ownTags = map[r.id] || []; if (!r.referentiel_id) return { ...r, categories_inv: ownTags }; const refTags = refCatMap[r.referentiel_id] || []; const refIdSet = new Set(refTags.map(t => t.id)); const ownIds = new Set(ownTags.map(t => t.id)); for (const tag of ownTags) tag.is_inherited = refIdSet.has(tag.id); const merged = [...ownTags]; for (const rt of refTags) { if (!ownIds.has(rt.id)) merged.push({ ...rt, is_inherited: true }); } merged.sort((a, b) => a.nom.localeCompare(b.nom)); return { ...r, categories_inv: merged }; }); } function attachSecteursInv(rows) { if (rows.length === 0) return rows; const ids = rows.map(r => r.id); const links = db.prepare(` SELECT ps.plateforme_id, s.id AS secteur_id, s.nom FROM plateforme_secteurs_inv ps JOIN secteurs_inv s ON s.id = ps.secteur_id WHERE ps.plateforme_id IN (${ids.map(() => '?').join(',')}) ORDER BY s.nom `).all(...ids); const map = {}; for (const l of links) { if (!map[l.plateforme_id]) map[l.plateforme_id] = []; map[l.plateforme_id].push({ id: l.secteur_id, nom: l.nom, is_inherited: false }); } const refIds = [...new Set(rows.filter(r => r.referentiel_id).map(r => r.referentiel_id))]; const refSectMap = {}; if (refIds.length > 0) { const refLinks = 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 (${refIds.map(() => '?').join(',')}) ORDER BY s.nom `).all(...refIds); for (const l of refLinks) { if (!refSectMap[l.referentiel_id]) refSectMap[l.referentiel_id] = []; refSectMap[l.referentiel_id].push({ id: l.secteur_id, nom: l.nom }); } } return rows.map(r => { const ownTags = map[r.id] || []; if (!r.referentiel_id) return { ...r, secteurs_inv: ownTags }; const refTags = refSectMap[r.referentiel_id] || []; const refIdSet = new Set(refTags.map(t => t.id)); const ownIds = new Set(ownTags.map(t => t.id)); for (const tag of ownTags) tag.is_inherited = refIdSet.has(tag.id); const merged = [...ownTags]; for (const rt of refTags) { if (!ownIds.has(rt.id)) merged.push({ ...rt, is_inherited: true }); } merged.sort((a, b) => a.nom.localeCompare(b.nom)); return { ...r, secteurs_inv: merged }; }); } function syncCategories(platId, catIds) { db.prepare('DELETE FROM plateforme_categories WHERE plateforme_id=?').run(platId); if (catIds.length === 0) return; const ins = db.prepare( 'INSERT OR IGNORE INTO plateforme_categories (plateforme_id, categorie_id) VALUES (?,?)' ); db.transaction((pid, ids) => { for (const cid of ids) ins.run(pid, cid); })(platId, catIds); } // ── Lecture référentiel (tous users authentifiés) ───────────────────────── router.get('/referentiel-list', (_req, res) => { const rows = db.prepare(` SELECT pr.id, pr.nom, pr.domiciliation, pr.fiscalite, pr.taux_fiscalite_locale, pr.type_produit_fiscal, pr.logo_filename, pr.icone_filename FROM plateformes_referentiel pr ORDER BY pr.nom `).all(); res.json(rows); }); router.get('/', (req, res) => { const rows = db.prepare(` SELECT p.id, p.nom, p.url, p.notes, p.domiciliation, p.fiscalite, p.taux_fiscalite_locale, p.type_produit_fiscal, p.methode_remboursement, p.investisseur_id, p.date_ouverture, p.logo_filename, p.icone_filename, p.created_at, p.type_pret_defaut, p.freq_interets_defaut, p.referentiel_id, p.overridden_fields, pr.nom AS referentiel_nom, pr.description AS referentiel_description, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom, inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal, (SELECT COUNT(*) FROM investissements i JOIN investisseurs inv2 ON inv2.id = i.investisseur_id WHERE i.plateforme_id = p.id AND inv2.user_id = p.user_id) AS nb_investissements FROM plateformes p LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id LEFT JOIN plateformes_referentiel pr ON pr.id = p.referentiel_id WHERE p.user_id = ? ORDER BY p.nom `).all(req.user.id); const enriched = rows.map(r => ({ ...r, overridden_fields: JSON.parse(r.overridden_fields || '[]'), })); const withCats = attachCategories(req.user.id, enriched); const withCatsInv = attachCategoriesInv(withCats); const withAll = attachSecteursInv(withCatsInv); res.json(withAll); }); router.post('/', (req, res, next) => { try { const body = Schema.parse(req.body); let fiscalite = body.domiciliation === 'FR' ? 'flat_tax' : body.fiscalite; let taux = fiscalite === 'avec_fiscalite_locale' ? (body.taux_fiscalite_locale ?? null) : null; let typeProduitFiscal = body.domiciliation === 'FR' ? (body.type_produit_fiscal ?? '2TT') : '2TT'; let referentielId = body.referentiel_id ?? null; let logoFilename = null; let iconeFilename = null; // Si un référentiel est sélectionné, hériter logo et icone if (referentielId) { const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(referentielId); if (!ref) throw new HttpError(404, 'Référentiel introuvable'); logoFilename = ref.logo_filename ?? null; iconeFilename = ref.icone_filename ?? null; } const r = db.prepare(` INSERT INTO plateformes (user_id, nom, url, notes, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, methode_remboursement, investisseur_id, date_ouverture, type_pret_defaut, freq_interets_defaut, logo_filename, icone_filename, referentiel_id, overridden_fields) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) `).run( req.user.id, body.nom, body.url || null, body.notes || null, body.domiciliation, fiscalite, taux, typeProduitFiscal, body.methode_remboursement, body.investisseur_id ?? null, body.date_ouverture || null, body.type_pret_defaut ?? null, body.freq_interets_defaut ?? null, logoFilename, iconeFilename, referentielId, '[]' ); const id = r.lastInsertRowid; syncCategories(id, body.categories); res.status(201).json({ id, ...body, fiscalite, taux_fiscalite_locale: taux, type_produit_fiscal: typeProduitFiscal, referentiel_id: referentielId, overridden_fields: [], }); } catch (e) { next(e); } }); // ── Multer memStorage 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/plateformes/export — exporte toutes les plateformes de l'user ─ router.get('/export', (req, res, next) => { try { let rows = db.prepare(` SELECT p.*, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom, inv.email AS investisseur_email FROM plateformes p LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id WHERE p.user_id = ? ORDER BY p.nom `).all(req.user.id); rows = attachCategoriesInv(rows); rows = attachSecteursInv(rows); // Attach legacy categories const platIds = rows.map(r => r.id); let catsLegacy = []; if (platIds.length > 0) { catsLegacy = db.prepare(` SELECT pc.plateforme_id, c.nom FROM plateforme_categories pc JOIN categories_plateforme c ON c.id = pc.categorie_id WHERE c.user_id = ? AND pc.plateforme_id IN (${platIds.map(() => '?').join(',')}) `).all(req.user.id, ...platIds); } const legacyMap = {}; for (const c of catsLegacy) { if (!legacyMap[c.plateforme_id]) legacyMap[c.plateforme_id] = []; legacyMap[c.plateforme_id].push(c.nom); } const entries = []; entries.push({ name: 'manifest.json', data: JSON.stringify({ version: '1.1', app: 'crowdlending', exported_at: new Date().toISOString(), count: rows.length, type: 'plateformes', includes_investisseurs: true }, null, 2) }); const dataRows = rows.map(r => ({ nom: r.nom, url: r.url, notes: r.notes, domiciliation: r.domiciliation, fiscalite: r.fiscalite, taux_fiscalite_locale: r.taux_fiscalite_locale, type_produit_fiscal: r.type_produit_fiscal, methode_remboursement: r.methode_remboursement, date_ouverture: r.date_ouverture, type_pret_defaut: r.type_pret_defaut, freq_interets_defaut: r.freq_interets_defaut, logo_filename: r.logo_filename, icone_filename: r.icone_filename, investisseur_nom: r.investisseur_nom, investisseur_prenom: r.investisseur_prenom, investisseur_email: r.investisseur_email, categories: legacyMap[r.id] || [], 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) }); // Export des investisseurs (détenteurs) liés aux plateformes const invIds = [...new Set(rows.map(r => r.investisseur_id).filter(Boolean))]; const invRows = invIds.length > 0 ? db.prepare(`SELECT nom, prenom, type, type_fiscal, notes, email FROM investisseurs WHERE id IN (${invIds.map(() => '?').join(',')}) AND user_id = ?`).all(...invIds, req.user.id) : []; if (invRows.length > 0) { entries.push({ name: 'investisseurs.json', data: JSON.stringify(invRows, null, 2) }); } 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 = 'plateformes-' + 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/plateformes/:id/export — exporte une plateforme ────────────── router.get('/:id/export', (req, res, next) => { try { const p = db.prepare(` SELECT p.*, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom, inv.email AS investisseur_email FROM plateformes p LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id WHERE p.id = ? AND p.user_id = ? `).get(req.params.id, req.user.id); if (!p) throw new HttpError(404, 'Plateforme introuvable'); const [withCatsInv] = attachCategoriesInv([p]); const [withAll] = attachSecteursInv([withCatsInv]); const catsLegacy = db.prepare(` SELECT c.nom FROM plateforme_categories pc JOIN categories_plateforme c ON c.id = pc.categorie_id WHERE pc.plateforme_id = ? AND c.user_id = ? `).all(p.id, req.user.id).map(c => c.nom); const entries = []; entries.push({ name: 'manifest.json', data: JSON.stringify({ version: '1.0', app: 'crowdlending', exported_at: new Date().toISOString(), count: 1, type: 'plateformes' }, null, 2) }); const dataRow = { nom: withAll.nom, url: withAll.url, notes: withAll.notes, domiciliation: withAll.domiciliation, fiscalite: withAll.fiscalite, taux_fiscalite_locale: withAll.taux_fiscalite_locale, type_produit_fiscal: withAll.type_produit_fiscal, methode_remboursement: withAll.methode_remboursement, date_ouverture: withAll.date_ouverture, type_pret_defaut: withAll.type_pret_defaut, freq_interets_defaut: withAll.freq_interets_defaut, logo_filename: withAll.logo_filename, icone_filename: withAll.icone_filename, investisseur_nom: withAll.investisseur_nom, investisseur_prenom: withAll.investisseur_prenom, investisseur_email: withAll.investisseur_email, categories: catsLegacy, categories_inv: (withAll.categories_inv || []).map(c => c.nom), secteurs_inv: (withAll.secteurs_inv || []).map(s => s.nom), }; entries.push({ name: 'data.json', data: JSON.stringify([dataRow], null, 2) }); // Export du détenteur lié à la plateforme if (withAll.investisseur_id) { const inv = db.prepare('SELECT nom, prenom, type, type_fiscal, notes, email FROM investisseurs WHERE id = ? AND user_id = ?').get(withAll.investisseur_id, req.user.id); if (inv) entries.push({ name: 'investisseurs.json', data: JSON.stringify([inv], null, 2) }); } for (const fname of [withAll.logo_filename, withAll.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 = withAll.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/plateformes/import-zip — importe un ZIP de plateformes ────── 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 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'); // Importer les investisseurs du ZIP (créer les manquants, dédupliquer par email puis nom) const invEntry = zipEntries.find(e => e.name === 'investisseurs.json'); if (invEntry) { const invList = JSON.parse(invEntry.data.toString('utf8')); for (const inv of (Array.isArray(invList) ? invList : [])) { if (!inv.nom) continue; // Déduplication : priorité à l'email, puis correspondance insensible à la casse sur le nom const existsByEmail = inv.email ? db.prepare('SELECT id FROM investisseurs WHERE LOWER(email) = LOWER(?) AND user_id = ?').get(inv.email, req.user.id) : null; const existsByNom = db.prepare('SELECT id FROM investisseurs WHERE LOWER(nom) = LOWER(?) AND user_id = ?').get(inv.nom, req.user.id); if (!existsByEmail && !existsByNom) { db.prepare('INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, notes, email) VALUES (?,?,?,?,?,?,?)') .run(req.user.id, inv.nom, inv.prenom ?? null, inv.type || 'famille', inv.type_fiscal ?? null, inv.notes ?? null, inv.email ?? null); } } } // Résoudre investisseur — priorité email, puis nom+prénom insensible à la casse const userInvestisseurs = db.prepare('SELECT * FROM investisseurs WHERE user_id = ?').all(req.user.id); function resolveInvestisseur(nom, prenom, email) { if (!nom && !email) return userInvestisseurs[0]?.id ?? null; if (email) { const byEmail = userInvestisseurs.find(i => i.email?.toLowerCase() === email.toLowerCase()); if (byEmail) return byEmail.id; } const match = userInvestisseurs.find(i => i.nom?.toLowerCase() === nom?.toLowerCase() && i.prenom?.toLowerCase() === (prenom || '').toLowerCase() ) || userInvestisseurs.find(i => i.nom?.toLowerCase() === nom?.toLowerCase()); return match?.id ?? userInvestisseurs[0]?.id ?? null; } // Résoudre categories_inv et secteurs_inv par nom (globaux puis user) 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 OR user_id = ?)`).get(trimmed, req.user.id); if (!row) { const r = db.prepare(`INSERT INTO ${table} (nom, user_id) VALUES (?, ?)`).run(trimmed, req.user.id); row = { id: r.lastInsertRowid }; } ids.push(row.id); } return ids; } // Résoudre catégories legacy function resolveLegacyCatIds(noms) { const ids = []; for (const nom of (noms || [])) { const trimmed = nom.trim(); if (!trimmed) continue; let row = db.prepare('SELECT id FROM categories_plateforme WHERE nom = ? AND user_id = ?').get(trimmed, req.user.id); if (!row) { const r = db.prepare('INSERT INTO categories_plateforme (nom, user_id) VALUES (?, ?)').run(trimmed, req.user.id); row = { id: r.lastInsertRowid }; } ids.push(row.id); } return ids; } 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 fiscalite = p.domiciliation === 'FR' ? 'flat_tax' : (p.fiscalite || 'flat_tax'); const taux = fiscalite === 'avec_fiscalite_locale' ? (p.taux_fiscalite_locale ?? null) : null; const investisseurId = resolveInvestisseur(p.investisseur_nom, p.investisseur_prenom, p.investisseur_email); const catsInvIds = resolveTagIds(p.categories_inv, 'categories_inv'); const sectsInvIds = resolveTagIds(p.secteurs_inv, 'secteurs_inv'); const legacyCatIds = resolveLegacyCatIds(p.categories); const existing = db.prepare('SELECT id FROM plateformes WHERE nom = ? AND user_id = ?').get(p.nom, req.user.id); let platId; const fields = [ p.url || null, p.notes || null, p.domiciliation || 'france', fiscalite, taux, p.type_produit_fiscal || '2TT', p.methode_remboursement || 'portefeuille', investisseurId, p.date_ouverture || null, p.type_pret_defaut || null, p.freq_interets_defaut || null, p.logo_filename || null, p.icone_filename || null, ]; if (existing) { db.prepare(` UPDATE plateformes SET url=?, notes=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, type_produit_fiscal=?, methode_remboursement=?, investisseur_id=?, date_ouverture=?, type_pret_defaut=?, freq_interets_defaut=?, logo_filename=?, icone_filename=? WHERE id=? AND user_id=? `).run(...fields, existing.id, req.user.id); platId = existing.id; updated++; } else { const r = db.prepare(` INSERT INTO plateformes (user_id, nom, url, notes, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, methode_remboursement, investisseur_id, date_ouverture, type_pret_defaut, freq_interets_defaut, logo_filename, icone_filename, overridden_fields) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) `).run(req.user.id, p.nom, ...fields, '[]'); platId = r.lastInsertRowid; created++; } // Sync catégories legacy syncCategories(platId, legacyCatIds); // Sync categories_inv db.prepare('DELETE FROM plateforme_categories_inv WHERE plateforme_id = ?').run(platId); for (const id of catsInvIds) db.prepare('INSERT OR IGNORE INTO plateforme_categories_inv (plateforme_id, categorie_id) VALUES (?,?)').run(platId, id); // Sync secteurs_inv db.prepare('DELETE FROM plateforme_secteurs_inv WHERE plateforme_id = ?').run(platId); for (const id of sectsInvIds) db.prepare('INSERT OR IGNORE INTO plateforme_secteurs_inv (plateforme_id, secteur_id) VALUES (?,?)').run(platId, id); // Images for (const fname of [p.logo_filename, p.icone_filename]) { if (!fname || !imageMap[fname]) continue; fs.writeFileSync(path.join(logosDir, fname), imageMap[fname]); } } }); tx(); res.json({ ok: true, created, updated, total: platforms.length }); } catch (e) { next(e); } }); router.put('/:id', (req, res, next) => { try { const body = Schema.parse(req.body); const fiscalite = body.domiciliation === 'FR' ? 'flat_tax' : body.fiscalite; const taux = fiscalite === 'avec_fiscalite_locale' ? (body.taux_fiscalite_locale ?? null) : null; const typeProduitFiscal = body.domiciliation === 'FR' ? (body.type_produit_fiscal ?? '2TT') : '2TT'; // Récupérer l'état actuel pour calculer les overrides const current = db.prepare('SELECT referentiel_id, overridden_fields, logo_filename FROM plateformes WHERE id = ? AND user_id = ?') .get(req.params.id, req.user.id); if (!current) throw new HttpError(404, 'Not found'); // Calculer overridden_fields si la plateforme est liée à un référentiel let overriddenFields = JSON.parse(current.overridden_fields || '[]'); if (current.referentiel_id) { const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(current.referentiel_id); const newValues = { nom: body.nom, url: body.url || null, domiciliation: body.domiciliation, fiscalite, taux_fiscalite_locale: taux, type_produit_fiscal: typeProduitFiscal, logo_filename: current.logo_filename, // logo géré séparément methode_remboursement: body.methode_remboursement, type_pret_defaut: body.type_pret_defaut ?? null, freq_interets_defaut: body.freq_interets_defaut ?? null, }; overriddenFields = computeOverridden(ref, newValues, overriddenFields); } const r = db.prepare(` UPDATE plateformes SET nom=?, url=?, notes=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, type_produit_fiscal=?, methode_remboursement=?, investisseur_id=?, date_ouverture=?, type_pret_defaut=?, freq_interets_defaut=?, overridden_fields=? WHERE id=? AND user_id=? `).run( body.nom, body.url || null, body.notes || null, body.domiciliation, fiscalite, taux, typeProduitFiscal, body.methode_remboursement, body.investisseur_id ?? null, body.date_ouverture || null, body.type_pret_defaut ?? null, body.freq_interets_defaut ?? null, JSON.stringify(overriddenFields), req.params.id, req.user.id ); if (r.changes === 0) throw new HttpError(404, 'Not found'); syncCategories(Number(req.params.id), body.categories); res.json({ id: Number(req.params.id), ...body, fiscalite, taux_fiscalite_locale: taux, type_produit_fiscal: typeProduitFiscal, referentiel_id: current.referentiel_id, overridden_fields: overriddenFields, }); } catch (e) { next(e); } }); router.delete('/:id', (req, res, next) => { try { // Récupère le logo avant suppression pour effacer le fichier const plat = db.prepare('SELECT logo_filename FROM plateformes WHERE id=? AND user_id=?') .get(req.params.id, req.user.id); if (!plat) throw new HttpError(404, 'Not found'); const r = db.prepare('DELETE FROM plateformes WHERE id=? AND user_id=?') .run(req.params.id, req.user.id); if (r.changes === 0) throw new HttpError(404, 'Not found'); if (plat.logo_filename) { const filePath = path.join(logosDir, plat.logo_filename); fs.unlink(filePath, () => {}); // silencieux si déjà supprimé } res.status(204).end(); } catch (e) { next(e); } }); // ── Reset aux valeurs du référentiel ────────────────────────────────────── router.post('/:id/reset', (req, res, next) => { try { const plat = db.prepare('SELECT * FROM plateformes WHERE id = ? AND user_id = ?') .get(req.params.id, req.user.id); if (!plat) throw new HttpError(404, 'Not found'); if (!plat.referentiel_id) throw new HttpError(400, 'Cette plateforme n\'est liée à aucun référentiel'); const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(plat.referentiel_id); if (!ref) throw new HttpError(404, 'Référentiel introuvable'); db.prepare(` UPDATE plateformes SET nom=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, type_produit_fiscal=?, logo_filename=?, overridden_fields='[]' WHERE id=? AND user_id=? `).run( ref.nom, ref.domiciliation, ref.fiscalite, ref.taux_fiscalite_locale ?? null, ref.type_produit_fiscal, ref.logo_filename ?? null, req.params.id, req.user.id ); const updated = db.prepare('SELECT * FROM plateformes WHERE id = ?').get(req.params.id); res.json({ ...updated, overridden_fields: [] }); } catch (e) { next(e); } }); // ── Upload logo ─────────────────────────────────────────────────────────── router.post('/:id/logo', upload.single('logo'), (req, res, next) => { try { if (!req.file) throw new HttpError(400, 'Aucun fichier reçu'); // Supprime l'ancien logo s'il diffère du nouveau const old = db.prepare('SELECT logo_filename FROM plateformes WHERE id=? AND user_id=?') .get(req.params.id, req.user.id); if (old?.logo_filename && old.logo_filename !== req.file.filename) { fs.unlink(path.join(logosDir, old.logo_filename), () => {}); } db.prepare('UPDATE plateformes SET logo_filename=? WHERE id=? AND user_id=?') .run(req.file.filename, req.params.id, req.user.id); res.json({ logo_filename: req.file.filename }); } catch (e) { next(e); } }); // ── Suppression logo ────────────────────────────────────────────────────── router.delete('/:id/logo', (req, res, next) => { try { const plat = db.prepare('SELECT logo_filename FROM plateformes WHERE id=? AND user_id=?') .get(req.params.id, req.user.id); if (!plat) throw new HttpError(404, 'Not found'); if (plat.logo_filename) { fs.unlink(path.join(logosDir, plat.logo_filename), () => {}); db.prepare("UPDATE plateformes SET logo_filename=NULL WHERE id=? AND user_id=?") .run(req.params.id, req.user.id); } res.status(204).end(); } catch (e) { next(e); } }); export default router;