Initial commit
This commit is contained in:
@@ -0,0 +1,693 @@
|
||||
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
|
||||
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;
|
||||
|
||||
// Si un référentiel est sélectionné, hériter ses valeurs (pas encore d'overrides)
|
||||
if (referentielId) {
|
||||
const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(referentielId);
|
||||
if (!ref) throw new HttpError(404, 'Référentiel introuvable');
|
||||
// Les champs du body priment (l'user peut déjà avoir modifié à la création)
|
||||
// On calcule les overrides par rapport au référentiel
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
referentielId,
|
||||
'[]' // toujours vide à la création — les overrides se calculent au premier PUT
|
||||
);
|
||||
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
|
||||
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.0', app: 'crowdlending', exported_at: new Date().toISOString(), count: rows.length, type: 'plateformes' }, 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,
|
||||
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) });
|
||||
|
||||
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
|
||||
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,
|
||||
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) });
|
||||
|
||||
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');
|
||||
|
||||
// Résoudre investisseur par nom+prénom
|
||||
const userInvestisseurs = db.prepare('SELECT * FROM investisseurs WHERE user_id = ?').all(req.user.id);
|
||||
function resolveInvestisseur(nom, prenom) {
|
||||
if (!nom) return userInvestisseurs[0]?.id ?? null;
|
||||
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);
|
||||
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;
|
||||
Reference in New Issue
Block a user