Initial commit
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { checkStatutsRetard } from '../jobs/autoStatut.js';
|
||||
|
||||
// ── Helpers similarité de noms ──────────────────────────────────────────── */
|
||||
|
||||
/** Distance de Levenshtein entre deux chaînes */
|
||||
function levenshtein(a, b) {
|
||||
const m = a.length, n = b.length;
|
||||
const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
dp[i][j] = a[i - 1] === b[j - 1]
|
||||
? dp[i - 1][j - 1]
|
||||
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
||||
}
|
||||
}
|
||||
return dp[m][n];
|
||||
}
|
||||
|
||||
/** Similarité normalisée [0..1] — insensible à la casse et aux accents */
|
||||
function normalize(s) {
|
||||
return (s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim();
|
||||
}
|
||||
function similarity(a, b) {
|
||||
const na = normalize(a), nb = normalize(b);
|
||||
if (!na && !nb) return 1;
|
||||
const maxLen = Math.max(na.length, nb.length);
|
||||
return maxLen === 0 ? 1 : 1 - levenshtein(na, nb) / maxLen;
|
||||
}
|
||||
|
||||
const SIMILARITY_THRESHOLD = 0.80;
|
||||
|
||||
// Registre des jobs disponibles (nom → fonction synchrone)
|
||||
const JOBS = {
|
||||
auto_statut_retard: checkStatutsRetard,
|
||||
};
|
||||
|
||||
const router = Router();
|
||||
// requireAuth + requireAdmin sont appliqués dans server.js avant ce router
|
||||
|
||||
/* ── Utilisateurs ─────────────────────────────────────────────────────── */
|
||||
|
||||
/** Liste tous les utilisateurs */
|
||||
router.get('/users', (req, res) => {
|
||||
const users = db.prepare(`
|
||||
SELECT id, email, display_name, role, created_at
|
||||
FROM users
|
||||
ORDER BY id ASC
|
||||
`).all();
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
/** Crée un utilisateur */
|
||||
const CreateUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
displayName: z.string().min(1).optional(),
|
||||
role: z.enum(['user', 'admin']).default('user'),
|
||||
});
|
||||
|
||||
router.post('/users', (req, res, next) => {
|
||||
try {
|
||||
const body = CreateUserSchema.parse(req.body);
|
||||
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(body.email);
|
||||
if (exists) throw new HttpError(409, 'Email déjà utilisé');
|
||||
|
||||
const hash = bcrypt.hashSync(body.password, 10);
|
||||
const result = db
|
||||
.prepare('INSERT INTO users (email, password_hash, display_name, role) VALUES (?, ?, ?, ?)')
|
||||
.run(body.email, hash, body.displayName || null, body.role);
|
||||
|
||||
const userId = result.lastInsertRowid;
|
||||
const fullName = body.displayName || body.email.split('@')[0];
|
||||
const prenom = fullName.includes(' ') ? fullName.split(' ')[0] : null;
|
||||
db.prepare(
|
||||
`INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal) VALUES (?, ?, ?, 'famille', 'PP')`
|
||||
).run(userId, fullName, prenom);
|
||||
|
||||
res.status(201).json({ id: userId, email: body.email, display_name: body.displayName || null, role: body.role });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Modifie le rôle d'un utilisateur */
|
||||
const PatchRoleSchema = z.object({
|
||||
role: z.enum(['user', 'admin']),
|
||||
});
|
||||
|
||||
router.patch('/users/:id/role', (req, res, next) => {
|
||||
try {
|
||||
const { role } = PatchRoleSchema.parse(req.body);
|
||||
const targetId = Number(req.params.id);
|
||||
|
||||
// Empêche un admin de se rétrograder lui-même
|
||||
if (targetId === req.user.id && role !== 'admin') {
|
||||
throw new HttpError(400, 'Vous ne pouvez pas vous rétrograder vous-même');
|
||||
}
|
||||
|
||||
const r = db.prepare("UPDATE users SET role=?, updated_at=datetime('now') WHERE id=?")
|
||||
.run(role, targetId);
|
||||
if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable');
|
||||
|
||||
res.json({ id: targetId, role });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Supprime un utilisateur (sauf soi-même) */
|
||||
router.delete('/users/:id', (req, res, next) => {
|
||||
try {
|
||||
const targetId = Number(req.params.id);
|
||||
if (targetId === req.user.id) {
|
||||
throw new HttpError(400, 'Vous ne pouvez pas supprimer votre propre compte');
|
||||
}
|
||||
const r = db.prepare('DELETE FROM users WHERE id = ?').run(targetId);
|
||||
if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable');
|
||||
res.status(204).end();
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/* ── Logs des jobs ────────────────────────────────────────────────────── */
|
||||
|
||||
router.get('/job-logs', (req, res) => {
|
||||
const limit = Math.min(Number(req.query.limit) || 100, 500);
|
||||
const offset = Number(req.query.offset) || 0;
|
||||
const job = req.query.job || null;
|
||||
|
||||
const conds = job ? 'WHERE job_name = ?' : '';
|
||||
const params = job ? [job, limit, offset] : [limit, offset];
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT id, job_name, run_at, status, nb_changes, details, error_msg
|
||||
FROM job_logs
|
||||
${conds}
|
||||
ORDER BY run_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params);
|
||||
|
||||
const total = db.prepare(`SELECT COUNT(*) AS n FROM job_logs ${conds}`)
|
||||
.get(...(job ? [job] : [])).n;
|
||||
|
||||
res.json({ total, rows });
|
||||
});
|
||||
|
||||
/* ── Plateformes orphelines (sans référentiel) ───────────────────────── */
|
||||
|
||||
/** Liste les plateformes user sans referentiel_id, avec suggestion de liaison si nom similaire */
|
||||
router.get('/plateformes-orphelines', (_req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT p.id, p.nom, p.domiciliation, p.fiscalite, p.created_at,
|
||||
u.id AS user_id, u.email AS user_email, u.display_name AS user_display_name
|
||||
FROM plateformes p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE p.referentiel_id IS NULL
|
||||
ORDER BY u.email, p.nom
|
||||
`).all();
|
||||
|
||||
// Charger le référentiel pour calculer les suggestions de liaison
|
||||
const refs = db.prepare('SELECT id, nom FROM plateformes_referentiel ORDER BY nom').all();
|
||||
|
||||
const enriched = rows.map(plat => {
|
||||
let bestRef = null, bestScore = 0;
|
||||
for (const ref of refs) {
|
||||
const score = similarity(plat.nom, ref.nom);
|
||||
if (score > bestScore) { bestScore = score; bestRef = ref; }
|
||||
}
|
||||
return {
|
||||
...plat,
|
||||
suggestion: bestScore >= SIMILARITY_THRESHOLD
|
||||
? { referentiel_id: bestRef.id, referentiel_nom: bestRef.nom, score: Math.round(bestScore * 100) }
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
res.json(enriched);
|
||||
});
|
||||
|
||||
/** Importe une plateforme orpheline dans le référentiel et la lie */
|
||||
router.post('/plateformes-orphelines/:id/importer', (req, res, next) => {
|
||||
try {
|
||||
const plat = db.prepare(`
|
||||
SELECT p.*, u.email AS user_email
|
||||
FROM plateformes p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE p.id = ?
|
||||
`).get(req.params.id);
|
||||
if (!plat) throw new HttpError(404, 'Plateforme introuvable');
|
||||
if (plat.referentiel_id) throw new HttpError(409, 'Plateforme deja liee a un referentiel');
|
||||
|
||||
// Verifier si un referentiel avec ce nom existe deja
|
||||
const existing = db.prepare('SELECT id FROM plateformes_referentiel WHERE nom = ?').get(plat.nom);
|
||||
if (existing) {
|
||||
// Lier simplement la plateforme au referentiel existant
|
||||
db.prepare(
|
||||
"UPDATE plateformes SET referentiel_id = ?, overridden_fields = '[]' WHERE id = ?"
|
||||
).run(existing.id, plat.id);
|
||||
const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(existing.id);
|
||||
return res.json({ linked: true, created: false, referentiel: ref });
|
||||
}
|
||||
|
||||
// Creer un nouveau referentiel depuis la plateforme
|
||||
db.transaction(() => {
|
||||
const r = db.prepare(`
|
||||
INSERT INTO plateformes_referentiel
|
||||
(nom, url, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, logo_filename, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?, datetime('now'))
|
||||
`).run(
|
||||
plat.nom, plat.url || null, plat.domiciliation, plat.fiscalite,
|
||||
plat.taux_fiscalite_locale ?? null, plat.type_produit_fiscal || '2TT',
|
||||
plat.logo_filename ?? null
|
||||
);
|
||||
const refId = r.lastInsertRowid;
|
||||
|
||||
// Copier les categories (noms) depuis la plateforme source
|
||||
const cats = db.prepare(`
|
||||
SELECT cp.nom FROM plateforme_categories pc
|
||||
JOIN categories_plateforme cp ON cp.id = pc.categorie_id
|
||||
WHERE pc.plateforme_id = ?
|
||||
`).all(plat.id);
|
||||
const insC = db.prepare('INSERT OR IGNORE INTO referentiel_categories (referentiel_id, categorie_nom) VALUES (?,?)');
|
||||
for (const c of cats) insC.run(refId, c.nom);
|
||||
|
||||
// Copier les criteres de notation
|
||||
const notations = db.prepare('SELECT * FROM notation_criteres WHERE plateforme_id = ?').all(plat.id);
|
||||
const insN = db.prepare(`
|
||||
INSERT INTO referentiel_notation (referentiel_id, nom, type, valeurs, min_val, max_val, description, ordre)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
`);
|
||||
for (const n of notations) insN.run(refId, n.nom, n.type, n.valeurs, n.min_val, n.max_val, n.description, n.ordre);
|
||||
|
||||
// Lier la plateforme au nouveau referentiel, overridden_fields vide
|
||||
db.prepare(
|
||||
"UPDATE plateformes SET referentiel_id = ?, overridden_fields = '[]' WHERE id = ?"
|
||||
).run(refId, plat.id);
|
||||
})();
|
||||
|
||||
const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE nom = ?').get(plat.nom);
|
||||
res.status(201).json({ linked: true, created: true, referentiel: ref });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Lie directement une plateforme orpheline à un référentiel existant (sans import) */
|
||||
router.post('/plateformes-orphelines/:id/lier', (req, res, next) => {
|
||||
try {
|
||||
const { referentiel_id } = req.body;
|
||||
if (!referentiel_id) throw new HttpError(400, 'referentiel_id requis');
|
||||
|
||||
const plat = db.prepare('SELECT id, referentiel_id FROM plateformes WHERE id = ?').get(req.params.id);
|
||||
if (!plat) throw new HttpError(404, 'Plateforme introuvable');
|
||||
if (plat.referentiel_id) throw new HttpError(409, 'Plateforme déjà liée à un référentiel');
|
||||
|
||||
const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(referentiel_id);
|
||||
if (!ref) throw new HttpError(404, 'Référentiel introuvable');
|
||||
|
||||
db.prepare(
|
||||
"UPDATE plateformes SET referentiel_id = ?, overridden_fields = '[]' WHERE id = ?"
|
||||
).run(ref.id, plat.id);
|
||||
|
||||
res.json({ linked: true, referentiel: ref });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/* -- Execution manuelle d'un job ----------------------------------------- */
|
||||
|
||||
router.post('/jobs/:name/run', (req, res, next) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const fn = JOBS[name];
|
||||
if (!fn) throw new HttpError(404, `Job inconnu : ${name}`);
|
||||
|
||||
const nbChanges = fn();
|
||||
const lastLog = db.prepare(
|
||||
'SELECT * FROM job_logs WHERE job_name = ? ORDER BY run_at DESC LIMIT 1'
|
||||
).get(name);
|
||||
|
||||
res.json({ ok: true, nb_changes: nbChanges, log: lastLog });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
/* ── Catégories & secteurs suggérés par les utilisateurs ─────────────── */
|
||||
|
||||
/** Compte les catégories et secteurs créés par les utilisateurs (non globaux) */
|
||||
router.get('/inv-suggestions-count', (_req, res) => {
|
||||
const cats = db.prepare('SELECT COUNT(*) AS n FROM categories_inv WHERE user_id IS NOT NULL').get().n;
|
||||
const sects = db.prepare('SELECT COUNT(*) AS n FROM secteurs_inv WHERE user_id IS NOT NULL').get().n;
|
||||
res.json({ cats, sects, total: cats + sects });
|
||||
});
|
||||
|
||||
/** Liste toutes les catégories et secteurs créés par les utilisateurs */
|
||||
router.get('/inv-suggestions', (_req, res) => {
|
||||
const categories = db.prepare(`
|
||||
SELECT c.id, c.nom, c.user_id, u.email, u.display_name,
|
||||
(SELECT COUNT(*) FROM plateforme_categories_inv WHERE categorie_id = c.id) AS nb_plateformes,
|
||||
(SELECT COUNT(*) FROM investissement_categories_inv WHERE categorie_id = c.id) AS nb_investissements
|
||||
FROM categories_inv c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE c.user_id IS NOT NULL
|
||||
ORDER BY u.email, c.nom
|
||||
`).all();
|
||||
|
||||
const secteurs = db.prepare(`
|
||||
SELECT s.id, s.nom, s.user_id, u.email, u.display_name,
|
||||
(SELECT COUNT(*) FROM plateforme_secteurs_inv WHERE secteur_id = s.id) AS nb_plateformes,
|
||||
(SELECT COUNT(*) FROM investissement_secteurs_inv WHERE secteur_id = s.id) AS nb_investissements
|
||||
FROM secteurs_inv s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.user_id IS NOT NULL
|
||||
ORDER BY u.email, s.nom
|
||||
`).all();
|
||||
|
||||
res.json({ categories, secteurs });
|
||||
});
|
||||
|
||||
/** Promeut une catégorie utilisateur en catégorie globale (user_id → NULL) */
|
||||
router.post('/inv-suggestions/categories/:id/promouvoir', (req, res, next) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT id, nom, user_id FROM categories_inv WHERE id = ?').get(req.params.id);
|
||||
if (!row) throw new HttpError(404, 'Catégorie introuvable');
|
||||
if (row.user_id === null) throw new HttpError(400, 'Déjà globale');
|
||||
const dup = db.prepare('SELECT id FROM categories_inv WHERE nom = ? AND user_id IS NULL').get(row.nom);
|
||||
if (dup) throw new HttpError(409, `Une catégorie globale "${row.nom}" existe déjà.`);
|
||||
db.prepare('UPDATE categories_inv SET user_id = NULL WHERE id = ?').run(row.id);
|
||||
res.json({ ok: true, msg: `"${row.nom}" promue en catégorie globale.` });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Supprime une catégorie suggérée */
|
||||
router.delete('/inv-suggestions/categories/:id', (req, res, next) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT id, nom, user_id FROM categories_inv WHERE id = ?').get(req.params.id);
|
||||
if (!row) throw new HttpError(404, 'Catégorie introuvable');
|
||||
if (row.user_id === null) throw new HttpError(403, 'Ne peut pas supprimer une catégorie globale depuis cette route.');
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM plateforme_categories_inv WHERE categorie_id = ?').run(row.id);
|
||||
db.prepare('DELETE FROM investissement_categories_inv WHERE categorie_id = ?').run(row.id);
|
||||
db.prepare('DELETE FROM categories_inv WHERE id = ?').run(row.id);
|
||||
})();
|
||||
res.json({ ok: true, msg: `"${row.nom}" supprimée.` });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Promeut un secteur utilisateur en secteur global */
|
||||
router.post('/inv-suggestions/secteurs/:id/promouvoir', (req, res, next) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT id, nom, user_id FROM secteurs_inv WHERE id = ?').get(req.params.id);
|
||||
if (!row) throw new HttpError(404, 'Secteur introuvable');
|
||||
if (row.user_id === null) throw new HttpError(400, 'Déjà global');
|
||||
const dup = db.prepare('SELECT id FROM secteurs_inv WHERE nom = ? AND user_id IS NULL').get(row.nom);
|
||||
if (dup) throw new HttpError(409, `Un secteur global "${row.nom}" existe déjà.`);
|
||||
db.prepare('UPDATE secteurs_inv SET user_id = NULL WHERE id = ?').run(row.id);
|
||||
res.json({ ok: true, msg: `"${row.nom}" promu en secteur global.` });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Supprime un secteur suggéré */
|
||||
router.delete('/inv-suggestions/secteurs/:id', (req, res, next) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT id, nom, user_id FROM secteurs_inv WHERE id = ?').get(req.params.id);
|
||||
if (!row) throw new HttpError(404, 'Secteur introuvable');
|
||||
if (row.user_id === null) throw new HttpError(403, 'Ne peut pas supprimer un secteur global depuis cette route.');
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM plateforme_secteurs_inv WHERE secteur_id = ?').run(row.id);
|
||||
db.prepare('DELETE FROM investissement_secteurs_inv WHERE secteur_id = ?').run(row.id);
|
||||
db.prepare('DELETE FROM secteurs_inv WHERE id = ?').run(row.id);
|
||||
})();
|
||||
res.json({ ok: true, msg: `"${row.nom}" supprimé.` });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
Reference in New Issue
Block a user