Reférentiel des institutions financières
This commit is contained in:
1 parent
f2bdf29b16
commit
1dcb33aa74
19 files changed
+1265
No files matched your search
@@ -3524,4 +3524,137 @@ db.exec(`
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_pe_pertes_inv ON investissement_pe_pertes(investissement_pe_id)');
|
||||
|
||||
// ── Chantier "Référentiel Institutions financières" (26/09/26, demande Olivier) ──────────
|
||||
// Catalogue admin de banques/courtiers/assureurs, sur le modèle de plateformes_referentiel
|
||||
// (cf. project_referentiel_plateformes.md) mais SANS les champs spécifiques aux plateformes
|
||||
// d'investissement (fiscalité, produit fiscal, frais...) et SANS rattachement workspace : une
|
||||
// institution financière est transverse à tous les espaces de travail (décision d'Olivier,
|
||||
// AskUserQuestion 26/09/26). Phase 1 uniquement : catalogue admin (logos/icônes/catégories).
|
||||
// Le rattachement à comptes.banque (Phase 2) fera l'objet d'un chantier séparé — cf. plan de
|
||||
// déploiement (doc projet claude/plan_referentiel_institutions_financieres.md).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS institutions_referentiel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
nom TEXT NOT NULL UNIQUE,
|
||||
url TEXT,
|
||||
domiciliation TEXT NOT NULL DEFAULT 'FR',
|
||||
description TEXT,
|
||||
logo_filename TEXT,
|
||||
icone_filename TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_instref_nom ON institutions_referentiel(nom)');
|
||||
|
||||
// Catégories (axe unique "type d'établissement"), gérées par l'admin — même patron que
|
||||
// categories_inv/referentiel_categories_inv (cf. plus haut) mais table dédiée : une catégorie
|
||||
// d'établissement financier (Banque, Assureur...) n'a pas de sens pour une plateforme
|
||||
// crowdlending/PE, on évite donc de mélanger les deux taxonomies.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS categories_institutions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
nom TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS referentiel_categories_institutions (
|
||||
institution_id INTEGER NOT NULL REFERENCES institutions_referentiel(id) ON DELETE CASCADE,
|
||||
categorie_id INTEGER NOT NULL REFERENCES categories_institutions(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (institution_id, categorie_id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Seed initial des catégories (idempotent — n'insère que si la table est vide ; l'admin reste
|
||||
// libre de renommer/supprimer ensuite depuis l'écran dédié).
|
||||
{
|
||||
const catInstCount = db.prepare('SELECT COUNT(*) AS n FROM categories_institutions').get().n;
|
||||
if (catInstCount === 0) {
|
||||
const insCatInst = db.prepare('INSERT OR IGNORE INTO categories_institutions (nom) VALUES (?)');
|
||||
db.transaction(() => {
|
||||
for (const nom of ['Banque', 'Néobanque', 'Courtier', 'Assureur', 'Société de gestion', 'Établissement de paiement', 'Autre']) {
|
||||
insCatInst.run(nom);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Seed initial des institutions financières (proposition validée par Olivier le 26/09/26,
|
||||
// via ajustements sur la liste par défaut) — idempotent, n'insère que si la table est vide.
|
||||
// Chaque institution peut porter plusieurs catégories (ex. Trade Republic : Néobanque + Courtier).
|
||||
{
|
||||
const instRefCount = db.prepare('SELECT COUNT(*) AS n FROM institutions_referentiel').get().n;
|
||||
if (instRefCount === 0) {
|
||||
const SEED_INSTITUTIONS = [
|
||||
{ nom: 'BNP Paribas', categories: ['Banque'] },
|
||||
{ nom: 'Société Générale', categories: ['Banque'] },
|
||||
{ nom: 'Crédit Agricole', categories: ['Banque'] },
|
||||
{ nom: 'Banque Populaire', categories: ['Banque'] },
|
||||
{ nom: "Caisse d'Épargne", categories: ['Banque'] },
|
||||
{ nom: 'Crédit Mutuel', categories: ['Banque'] },
|
||||
{ nom: 'CIC', categories: ['Banque'] },
|
||||
{ nom: 'La Banque Postale', categories: ['Banque'] },
|
||||
{ nom: 'LCL', categories: ['Banque'] },
|
||||
{ nom: 'HSBC Continental Europe (CCF)', categories: ['Banque'] },
|
||||
{ nom: 'Boursorama Banque', categories: ['Néobanque'] },
|
||||
{ nom: 'Hello bank!', categories: ['Néobanque'] },
|
||||
{ nom: 'Fortuneo', categories: ['Néobanque'] },
|
||||
{ nom: 'BforBank', categories: ['Néobanque'] },
|
||||
{ nom: 'Monabanq', categories: ['Néobanque'] },
|
||||
{ nom: 'N26', categories: ['Néobanque'] },
|
||||
{ nom: 'Revolut', categories: ['Néobanque'] },
|
||||
{ nom: 'Qonto', categories: ['Néobanque'] },
|
||||
{ nom: 'Trade Republic', categories: ['Néobanque', 'Courtier'] },
|
||||
{ nom: 'Saxo Banque France', categories: ['Néobanque'] },
|
||||
{ nom: 'Wise', categories: ['Néobanque'] },
|
||||
{ nom: 'Bourse Direct', categories: ['Courtier'] },
|
||||
{ nom: 'DEGIRO', categories: ['Courtier'] },
|
||||
{ nom: 'Interactive Brokers', categories: ['Courtier'] },
|
||||
{ nom: 'EasyBourse', categories: ['Courtier'] },
|
||||
{ nom: 'AXA France', categories: ['Assureur'] },
|
||||
{ nom: 'Allianz France', categories: ['Assureur'] },
|
||||
{ nom: 'Generali France', categories: ['Assureur'] },
|
||||
{ nom: 'CNP Assurances', categories: ['Assureur'] },
|
||||
{ nom: 'MAIF', categories: ['Assureur'] },
|
||||
{ nom: 'MACIF', categories: ['Assureur'] },
|
||||
{ nom: 'Groupama', categories: ['Assureur'] },
|
||||
{ nom: 'Crédit Agricole Assurances (Predica)', categories: ['Assureur'] },
|
||||
{ nom: 'BNP Paribas Cardif', categories: ['Assureur'] },
|
||||
{ nom: 'Société Générale Assurances (Sogecap)', categories: ['Assureur'] },
|
||||
{ nom: 'Swiss Life France', categories: ['Assureur'] },
|
||||
{ nom: 'Abeille Assurances (ex-Aviva France)', categories: ['Assureur'] },
|
||||
{ nom: 'Suravenir (Crédit Mutuel Arkéa)', categories: ['Assureur'] },
|
||||
{ nom: 'Spirica', categories: ['Assureur'] },
|
||||
{ nom: 'Amundi', categories: ['Société de gestion'] },
|
||||
{ nom: 'Natixis Investment Managers', categories: ['Société de gestion'] },
|
||||
{ nom: 'BNP Paribas Asset Management', categories: ['Société de gestion'] },
|
||||
{ nom: 'Lazard Frères Gestion', categories: ['Société de gestion'] },
|
||||
{ nom: 'Carmignac', categories: ['Société de gestion'] },
|
||||
{ nom: 'Tikehau Capital', categories: ['Société de gestion'] },
|
||||
{ nom: "La Financière de l'Échiquier", categories: ['Société de gestion'] },
|
||||
{ nom: 'Eiffel Investment Group', categories: ['Société de gestion'] },
|
||||
{ nom: 'Lydia (Sumeria)', categories: ['Établissement de paiement'] },
|
||||
{ nom: 'PayPal', categories: ['Établissement de paiement'] },
|
||||
{ nom: 'Nickel', categories: ['Établissement de paiement'] },
|
||||
];
|
||||
const insInstRef = db.prepare('INSERT OR IGNORE INTO institutions_referentiel (nom) VALUES (?)');
|
||||
const getInstRefId = db.prepare('SELECT id FROM institutions_referentiel WHERE nom = ?');
|
||||
const getCatInstId = db.prepare('SELECT id FROM categories_institutions WHERE nom = ?');
|
||||
const linkInstCat = db.prepare('INSERT OR IGNORE INTO referentiel_categories_institutions (institution_id, categorie_id) VALUES (?, ?)');
|
||||
db.transaction(() => {
|
||||
for (const { nom, categories } of SEED_INSTITUTIONS) {
|
||||
insInstRef.run(nom);
|
||||
const inst = getInstRefId.get(nom);
|
||||
if (!inst) continue;
|
||||
for (const catNom of categories) {
|
||||
const cat = getCatInstId.get(catNom);
|
||||
if (cat) linkInstCat.run(inst.id, cat.id);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
export default db;
|
||||
@@ -0,0 +1,284 @@
|
||||
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 { HttpError } from '../middleware/errorHandler.js';
|
||||
|
||||
/**
|
||||
* routes/institutionsReferentiel.js — Référentiel admin des institutions financières
|
||||
* (banques, courtiers, assureurs...) — chantier "Référentiel Institutions financières"
|
||||
* (26/09/26, demande Olivier).
|
||||
*
|
||||
* Phase 1 uniquement (cf. plan de déploiement, doc projet) : catalogue admin (logo, icône,
|
||||
* catégories) — même patron que routes/referentiel.js (plateformes_referentiel), mais SANS les
|
||||
* champs spécifiques aux plateformes d'investissement (fiscalité, produit fiscal, frais,
|
||||
* méthode de remboursement...) et SANS rattachement workspace : une institution financière est
|
||||
* transverse à tous les espaces de travail (décision d'Olivier, AskUserQuestion 26/09/26).
|
||||
* Aucun consommateur applicatif pour l'instant (Phase 2, plus tard : comptes.banque) — donc pas
|
||||
* de mécanique de "poussée" vers des enregistrements liés, contrairement à referentiel.js.
|
||||
*
|
||||
* Le dossier de stockage des images est partagé avec le référentiel des plateformes
|
||||
* (<DATA_DIR>/logos/) plutôt qu'un sous-dossier dédié — même volumétrie modeste, noms de
|
||||
* fichiers déjà uniques (préfixe inst_ + id + timestamp) donc aucun risque de collision.
|
||||
*/
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dataDir = process.env.DATA_DIR
|
||||
? path.resolve(process.env.DATA_DIR)
|
||||
: path.resolve(__dirname, '../../../data'); // fallback dev : 3 niveaux au-dessus de routes/
|
||||
const logosDir = path.join(dataDir, 'logos');
|
||||
fs.mkdirSync(logosDir, { recursive: true });
|
||||
|
||||
// ── Traitement image (dupliqué depuis referentiel.js/plateformes.js — même précédent
|
||||
// déjà en place dans ce fichier : "réutilisé depuis plateformes.js" y est aussi une
|
||||
// duplication volontaire plutôt qu'un module partagé) ──────────────────────────────
|
||||
async function processLogoFile(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === '.svg') {
|
||||
try {
|
||||
let svg = fs.readFileSync(filePath, 'utf8');
|
||||
svg = svg.replace(/(<svg\b[^>]*)\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('[inst-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('[inst-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, `inst_${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(10).default('FR'),
|
||||
description: z.string().nullable().optional(),
|
||||
categories_ids: z.array(z.number().int()).optional().default([]),
|
||||
});
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Catégories (type d'établissement) attachées à chaque entrée */
|
||||
function attachCategories(rows) {
|
||||
if (rows.length === 0) return rows;
|
||||
const ids = rows.map(r => r.id);
|
||||
const links = db.prepare(`
|
||||
SELECT rc.institution_id, c.id AS categorie_id, c.nom
|
||||
FROM referentiel_categories_institutions rc
|
||||
JOIN categories_institutions c ON c.id = rc.categorie_id
|
||||
WHERE rc.institution_id IN (${ids.map(() => '?').join(',')})
|
||||
ORDER BY c.nom
|
||||
`).all(...ids);
|
||||
const map = {};
|
||||
for (const l of links) {
|
||||
if (!map[l.institution_id]) map[l.institution_id] = [];
|
||||
map[l.institution_id].push({ id: l.categorie_id, nom: l.nom });
|
||||
}
|
||||
return rows.map(r => ({ ...r, categories: map[r.id] || [] }));
|
||||
}
|
||||
|
||||
function saveCategories(institutionId, categoriesIds) {
|
||||
db.prepare('DELETE FROM referentiel_categories_institutions WHERE institution_id = ?').run(institutionId);
|
||||
if (categoriesIds && categoriesIds.length > 0) {
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO referentiel_categories_institutions (institution_id, categorie_id) VALUES (?,?)');
|
||||
for (const id of categoriesIds) ins.run(institutionId, id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /api/institutions-referentiel ───────────────────────────────────────
|
||||
router.get('/', (_req, res) => {
|
||||
let rows = db.prepare('SELECT * FROM institutions_referentiel ORDER BY nom').all();
|
||||
rows = attachCategories(rows);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// ── GET /api/institutions-referentiel/:id ───────────────────────────────────
|
||||
router.get('/:id', (req, res, next) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id);
|
||||
if (!row) throw new HttpError(404, 'Institution introuvable');
|
||||
const [enriched] = attachCategories([row]);
|
||||
res.json(enriched);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── POST /api/institutions-referentiel ──────────────────────────────────────
|
||||
router.post('/', (req, res, next) => {
|
||||
try {
|
||||
const data = Schema.parse(req.body);
|
||||
const existing = db.prepare('SELECT id FROM institutions_referentiel WHERE nom = ?').get(data.nom);
|
||||
if (existing) throw new HttpError(409, `Une institution "${data.nom}" existe déjà`);
|
||||
|
||||
const r = db.prepare(`
|
||||
INSERT INTO institutions_referentiel (nom, url, domiciliation, description, updated_at)
|
||||
VALUES (?,?,?,?, datetime('now'))
|
||||
`).run(data.nom, data.url || null, data.domiciliation, data.description || null);
|
||||
|
||||
const institutionId = r.lastInsertRowid;
|
||||
db.transaction(() => saveCategories(institutionId, data.categories_ids))();
|
||||
|
||||
const row = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(institutionId);
|
||||
const [enriched] = attachCategories([row]);
|
||||
res.status(201).json(enriched);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── PUT /api/institutions-referentiel/:id ───────────────────────────────────
|
||||
router.put('/:id', (req, res, next) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id);
|
||||
if (!row) throw new HttpError(404, 'Institution introuvable');
|
||||
|
||||
const data = Schema.parse(req.body);
|
||||
if (data.nom !== row.nom) {
|
||||
const dup = db.prepare('SELECT id FROM institutions_referentiel WHERE nom = ? AND id != ?').get(data.nom, row.id);
|
||||
if (dup) throw new HttpError(409, `Une institution "${data.nom}" existe déjà`);
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE institutions_referentiel
|
||||
SET nom=?, url=?, domiciliation=?, description=?, updated_at=datetime('now')
|
||||
WHERE id=?
|
||||
`).run(data.nom, data.url || null, data.domiciliation, data.description || null, row.id);
|
||||
|
||||
db.transaction(() => saveCategories(row.id, data.categories_ids))();
|
||||
|
||||
const updated = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(row.id);
|
||||
const [enriched] = attachCategories([updated]);
|
||||
res.json(enriched);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── DELETE /api/institutions-referentiel/:id ────────────────────────────────
|
||||
router.delete('/:id', (req, res, next) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id);
|
||||
if (!row) throw new HttpError(404, 'Institution introuvable');
|
||||
|
||||
for (const fname of [row.logo_filename, row.icone_filename]) {
|
||||
if (!fname) continue;
|
||||
const fpath = path.join(logosDir, fname);
|
||||
if (fs.existsSync(fpath)) fs.unlink(fpath, () => {});
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM institutions_referentiel WHERE id = ?').run(row.id);
|
||||
res.json({ deleted: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── POST /api/institutions-referentiel/:id/logo ─────────────────────────────
|
||||
router.post('/:id/logo', upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id);
|
||||
if (!ref) throw new HttpError(404, 'Institution introuvable');
|
||||
if (!req.file) throw new HttpError(400, 'Fichier requis');
|
||||
|
||||
const finalPath = await processLogoFile(req.file.path);
|
||||
const filename = path.basename(finalPath);
|
||||
|
||||
if (ref.logo_filename) {
|
||||
const old = path.join(logosDir, ref.logo_filename);
|
||||
if (fs.existsSync(old)) fs.unlink(old, () => {});
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE institutions_referentiel SET logo_filename=?, updated_at=datetime('now') WHERE id=?`)
|
||||
.run(filename, ref.id);
|
||||
|
||||
res.json({ logo_filename: filename });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── DELETE /api/institutions-referentiel/:id/logo ───────────────────────────
|
||||
router.delete('/:id/logo', (req, res, next) => {
|
||||
try {
|
||||
const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id);
|
||||
if (!ref) throw new HttpError(404, 'Institution introuvable');
|
||||
|
||||
if (ref.logo_filename) {
|
||||
const filePath = path.join(logosDir, ref.logo_filename);
|
||||
if (fs.existsSync(filePath)) fs.unlink(filePath, () => {});
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE institutions_referentiel SET logo_filename=NULL, updated_at=datetime('now') WHERE id=?`).run(ref.id);
|
||||
res.json({ deleted: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── POST /api/institutions-referentiel/:id/icone ────────────────────────────
|
||||
router.post('/:id/icone', upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id);
|
||||
if (!ref) throw new HttpError(404, 'Institution 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 institutions_referentiel SET icone_filename=?, updated_at=datetime('now') WHERE id=?`)
|
||||
.run(filename, ref.id);
|
||||
|
||||
res.json({ icone_filename: filename });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── DELETE /api/institutions-referentiel/:id/icone ──────────────────────────
|
||||
router.delete('/:id/icone', (req, res, next) => {
|
||||
try {
|
||||
const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id);
|
||||
if (!ref) throw new HttpError(404, 'Institution introuvable');
|
||||
|
||||
if (ref.icone_filename) {
|
||||
const filePath = path.join(logosDir, ref.icone_filename);
|
||||
if (fs.existsSync(filePath)) fs.unlink(filePath, () => {});
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE institutions_referentiel SET icone_filename=NULL, updated_at=datetime('now') WHERE id=?`).run(ref.id);
|
||||
res.json({ deleted: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
|
||||
/**
|
||||
* routes/ref-categories-institutions.js — liste globale des catégories du référentiel
|
||||
* "Institutions financières" (type d'établissement : Banque, Courtier, Assureur...).
|
||||
* Même patron que routes/ref-categories.js (categories_inv), table dédiée
|
||||
* (categories_institutions) — cf. institutionsReferentiel.js.
|
||||
*/
|
||||
|
||||
const router = Router();
|
||||
// Toutes ces routes sont montées sous requireAdmin dans server.js
|
||||
|
||||
/* GET /api/ref-categories-institutions — liste complète */
|
||||
router.get('/', (_req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT c.id, c.nom,
|
||||
COUNT(rc.institution_id) AS nb_utilises
|
||||
FROM categories_institutions c
|
||||
LEFT JOIN referentiel_categories_institutions rc ON rc.categorie_id = c.id
|
||||
GROUP BY c.id
|
||||
ORDER BY c.nom
|
||||
`).all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
/* POST /api/ref-categories-institutions — créer */
|
||||
router.post('/', (req, res, next) => {
|
||||
try {
|
||||
const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body);
|
||||
const exists = db.prepare('SELECT id FROM categories_institutions WHERE nom = ?').get(nom.trim());
|
||||
if (exists) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`);
|
||||
const r = db.prepare('INSERT INTO categories_institutions (nom) VALUES (?)').run(nom.trim());
|
||||
res.status(201).json({ id: r.lastInsertRowid, nom: nom.trim(), nb_utilises: 0 });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/* PUT /api/ref-categories-institutions/:id — renommer (propage automatiquement) */
|
||||
router.put('/:id', (req, res, next) => {
|
||||
try {
|
||||
const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body);
|
||||
const row = db.prepare('SELECT id, nom FROM categories_institutions WHERE id = ?').get(req.params.id);
|
||||
if (!row) throw new HttpError(404, 'Catégorie introuvable');
|
||||
const dup = db.prepare('SELECT id FROM categories_institutions WHERE nom = ? AND id != ?').get(nom.trim(), row.id);
|
||||
if (dup) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`);
|
||||
db.prepare('UPDATE categories_institutions SET nom = ? WHERE id = ?').run(nom.trim(), row.id);
|
||||
const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_institutions WHERE categorie_id = ?').get(row.id).n;
|
||||
res.json({ id: row.id, nom: nom.trim(), nb_utilises: nb });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/* DELETE /api/ref-categories-institutions/:id — supprimer si non utilisée */
|
||||
router.delete('/:id', (req, res, next) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT id FROM categories_institutions WHERE id = ?').get(req.params.id);
|
||||
if (!row) throw new HttpError(404, 'Catégorie introuvable');
|
||||
const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_institutions WHERE categorie_id = ?').get(row.id).n;
|
||||
if (nb > 0) throw new HttpError(409, `Catégorie utilisée par ${nb} institution(s) — retirez-la d'abord.`);
|
||||
db.prepare('DELETE FROM categories_institutions WHERE id = ?').run(row.id);
|
||||
res.status(204).end();
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/* POST /api/ref-categories-institutions/:id/merge — fusionner dans une autre catégorie */
|
||||
router.post('/:id/merge', (req, res, next) => {
|
||||
try {
|
||||
const { target_id } = z.object({ target_id: z.number().int() }).parse(req.body);
|
||||
const source = db.prepare('SELECT id, nom FROM categories_institutions WHERE id = ?').get(req.params.id);
|
||||
if (!source) throw new HttpError(404, 'Catégorie source introuvable');
|
||||
const target = db.prepare('SELECT id, nom FROM categories_institutions WHERE id = ?').get(target_id);
|
||||
if (!target) throw new HttpError(404, 'Catégorie cible introuvable');
|
||||
if (source.id === target.id) throw new HttpError(400, 'Source et cible identiques');
|
||||
|
||||
const result = db.transaction(() => {
|
||||
const refs = db.prepare(
|
||||
'SELECT institution_id FROM referentiel_categories_institutions WHERE categorie_id = ?'
|
||||
).all(source.id);
|
||||
let added = 0, skipped = 0;
|
||||
for (const { institution_id } of refs) {
|
||||
const exists = db.prepare(
|
||||
'SELECT 1 FROM referentiel_categories_institutions WHERE institution_id = ? AND categorie_id = ?'
|
||||
).get(institution_id, target.id);
|
||||
if (!exists) {
|
||||
db.prepare(
|
||||
'INSERT INTO referentiel_categories_institutions (institution_id, categorie_id) VALUES (?, ?)'
|
||||
).run(institution_id, target.id);
|
||||
added++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
db.prepare('DELETE FROM referentiel_categories_institutions WHERE categorie_id = ?').run(source.id);
|
||||
db.prepare('DELETE FROM categories_institutions WHERE id = ?').run(source.id);
|
||||
return { added, skipped, total: refs.length };
|
||||
})();
|
||||
|
||||
res.json({ ok: true, ...result,
|
||||
message: `Fusion terminée : ${result.added} lien(s) ajouté(s), ${result.skipped} déjà présent(s). "${source.nom}" supprimée.` });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -50,6 +50,8 @@ import adminAiRouter from './routes/adminAi.js';
|
||||
import tauxCreditImpotRouter from './routes/tauxCreditImpot.js';
|
||||
import referentielRouter from './routes/referentiel.js';
|
||||
import referentielPublicRouter from './routes/referentielPublic.js';
|
||||
import institutionsReferentielRouter from './routes/institutionsReferentiel.js';
|
||||
import refCategoriesInstitutionsRouter from './routes/ref-categories-institutions.js';
|
||||
import refCategoriesRouter from './routes/ref-categories.js';
|
||||
import refSecteursRouter from './routes/ref-secteurs.js';
|
||||
import categoriesInvRouter from './routes/categories-inv.js';
|
||||
@@ -168,6 +170,10 @@ app.use('/api/invitations', invitationsRouter);
|
||||
app.use('/api/taux-credit-impot', requireAuth, tauxCreditImpotRouter);
|
||||
app.use('/api/referentiel', requireAuth, requireAdmin, referentielRouter);
|
||||
app.use('/api/referentiel-public', requireAuth, referentielPublicRouter);
|
||||
// Chantier "Référentiel Institutions financières" (26/09/26, demande Olivier) — Phase 1 :
|
||||
// catalogue admin transverse aux workspaces, cf. institutionsReferentiel.js.
|
||||
app.use('/api/institutions-referentiel', requireAuth, requireAdmin, institutionsReferentielRouter);
|
||||
app.use('/api/ref-categories-institutions', requireAuth, requireAdmin, refCategoriesInstitutionsRouter);
|
||||
app.use('/api/ref-categories', requireAuth, requireAdmin, refCategoriesRouter);
|
||||
app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter);
|
||||
app.use('/api/categories-inv', requireAuth, categoriesInvRouter);
|
||||
|
||||
Reference in new issue
Block a user