Création de la feature API V1

This commit is contained in:
2026-07-14 16:22:34 +02:00
parent f9c5316303
commit c843464ccd
14 changed files with 1263 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
import { Router } from 'express';
import crypto from 'node:crypto';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
const router = Router();
const KEY_PREFIX_LEN = 12; // ex: "clk_live_ab3" — assez pour identifier sans exposer le secret
function generateKey() {
const secret = crypto.randomBytes(24).toString('hex'); // 48 caractères hex
const full = `clk_live_${secret}`;
const hash = crypto.createHash('sha256').update(full).digest('hex');
return { full, hash, prefix: full.slice(0, KEY_PREFIX_LEN) };
}
/* ── GET /api/api-keys ── liste des clés de l'utilisateur connecté ──────── */
router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id,
i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
FROM api_keys k
JOIN investisseurs i ON i.id = k.investisseur_id
WHERE k.user_id = ?
ORDER BY k.revoked_at IS NOT NULL, k.created_at DESC
`).all(req.user.id);
res.json(rows);
});
/* ── POST /api/api-keys ── créer une nouvelle clé (nom + investisseur) ──── */
router.post('/', (req, res, next) => {
try {
const nom = (req.body?.nom || '').trim();
const investisseur_id = Number(req.body?.investisseur_id);
if (!nom) throw new HttpError(400, 'Le nom de la clé est requis');
if (nom.length > 100) throw new HttpError(400, 'Le nom de la clé est trop long (100 caractères max)');
if (!Number.isInteger(investisseur_id)) throw new HttpError(400, 'investisseur_id est requis');
const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?')
.get(investisseur_id, req.user.id);
if (!inv) throw new HttpError(404, 'Investisseur introuvable');
const { full, hash, prefix } = generateKey();
const info = db.prepare(`
INSERT INTO api_keys (user_id, investisseur_id, nom, key_prefix, key_hash, scopes)
VALUES (?, ?, ?, ?, ?, 'read')
`).run(req.user.id, investisseur_id, nom, prefix, hash);
const saved = db.prepare(`
SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id,
i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id
WHERE k.id = ?
`).get(info.lastInsertRowid);
// La valeur en clair n'est renvoyée qu'ici, une seule fois.
res.status(201).json({ ...saved, key: full });
} catch (e) { next(e); }
});
/* ── PATCH /api/api-keys/:id ── renommer une clé ─────────────────────────── */
router.patch('/:id', (req, res, next) => {
try {
const nom = (req.body?.nom || '').trim();
if (!nom) throw new HttpError(400, 'Le nom de la clé est requis');
if (nom.length > 100) throw new HttpError(400, 'Le nom de la clé est trop long (100 caractères max)');
const existing = db.prepare('SELECT id FROM api_keys WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!existing) throw new HttpError(404, 'Clé API introuvable');
db.prepare('UPDATE api_keys SET nom = ? WHERE id = ?').run(nom, req.params.id);
const saved = db.prepare(`
SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id,
i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id
WHERE k.id = ?
`).get(req.params.id);
res.json(saved);
} catch (e) { next(e); }
});
/* ── DELETE /api/api-keys/:id ── révoque la clé (soft-delete) ───────────── */
router.delete('/:id', (req, res, next) => {
try {
const existing = db.prepare('SELECT id, revoked_at FROM api_keys WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!existing) throw new HttpError(404, 'Clé API introuvable');
if (existing.revoked_at) return res.json({ revoked: true });
db.prepare(`UPDATE api_keys SET revoked_at = datetime('now') WHERE id = ?`).run(req.params.id);
res.json({ revoked: true });
} catch (e) { next(e); }
});
/* ── DELETE /api/api-keys/:id/purge ── suppression définitive de la ligne ──
Distinct de la révocation ci-dessus : ici la clé disparaît complètement
(active ou déjà révoquée). Le frontend affiche un avertissement avant
d'appeler cette route si la clé est encore active. ─────────────────── */
router.delete('/:id/purge', (req, res, next) => {
try {
const existing = db.prepare('SELECT id FROM api_keys WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!existing) throw new HttpError(404, 'Clé API introuvable');
db.prepare('DELETE FROM api_keys WHERE id = ?').run(req.params.id);
res.json({ deleted: true });
} catch (e) { next(e); }
});
export default router;
+49
View File
@@ -0,0 +1,49 @@
import { Router } from 'express';
import db from '../../db/index.js';
const router = Router();
/**
* @openapi
* /dashboard:
* get:
* summary: Synthèse du portefeuille (KPIs)
* tags: [Dashboard]
* security: [{ ApiKeyAuth: [] }]
* responses:
* 200: { description: Synthèse KPI }
*/
router.get('/', (req, res) => {
const invId = req.investisseurId;
const investissements = db.prepare(`
SELECT
COUNT(*) AS nb_investissements,
COALESCE(SUM(montant_investi), 0) AS total_investi,
COALESCE(SUM(CASE WHEN statut='en_cours' THEN montant_investi END), 0) AS encours,
COALESCE(SUM(CASE WHEN statut='rembourse' THEN montant_investi END), 0) AS rembourse
FROM investissements WHERE investisseur_id = ?
`).get(invId);
const interets = db.prepare(`
SELECT
COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
COALESCE(SUM(r.interets_nets), 0) AS interets_nets,
COALESCE(SUM(r.capital), 0) AS capital_recu,
COALESCE(SUM(r.net_recu), 0) AS net_recu_total
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
WHERE i.investisseur_id = ?
`).get(invId);
const cash = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN type='depot' THEN montant END), 0) AS total_depots,
COALESCE(SUM(CASE WHEN type='retrait' THEN montant END), 0) AS total_retraits
FROM depots_retraits WHERE investisseur_id = ?
`).get(invId);
res.json({ investissements, interets, cash });
});
export default router;
+28
View File
@@ -0,0 +1,28 @@
import { Router } from 'express';
import db from '../../db/index.js';
const router = Router();
/**
* @openapi
* /depots-retraits:
* get:
* summary: Liste des mouvements de cash (dépôts / retraits)
* tags: [Dépôts / Retraits]
* security: [{ ApiKeyAuth: [] }]
* responses:
* 200: { description: Liste des mouvements }
*/
router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT dr.id, dr.date_operation, p.nom AS plateforme_nom, dr.type,
dr.montant, dr.libelle
FROM depots_retraits dr
JOIN plateformes p ON p.id = dr.plateforme_id
WHERE dr.investisseur_id = ?
ORDER BY dr.date_operation DESC
`).all(req.investisseurId);
res.json(rows);
});
export default router;
+16
View File
@@ -0,0 +1,16 @@
import { Router } from 'express';
import investisseurRouter from './investisseur.js';
import investissementsRouter from './investissements.js';
import remboursementsRouter from './remboursements.js';
import depotsRetraitsRouter from './depotsRetraits.js';
import dashboardRouter from './dashboard.js';
const router = Router();
router.use('/investisseur', investisseurRouter);
router.use('/investissements', investissementsRouter);
router.use('/remboursements', remboursementsRouter);
router.use('/depots-retraits', depotsRetraitsRouter);
router.use('/dashboard', dashboardRouter);
export default router;
+82
View File
@@ -0,0 +1,82 @@
import { Router } from 'express';
import db from '../../db/index.js';
import { HttpError } from '../../middleware/errorHandler.js';
const router = Router();
const LIST_COLUMNS = `
i.id, i.nom_projet, i.emetteur, p.nom AS plateforme_nom,
i.date_souscription, i.date_premiere_echeance, i.date_cible,
i.montant_investi, i.taux_interet, i.duree_mois,
i.type_remb, i.freq_interets, i.statut, i.reference
`;
/**
* @openapi
* /investissements:
* get:
* summary: Liste des investissements de l'investisseur
* tags: [Investissements]
* security: [{ ApiKeyAuth: [] }]
* parameters:
* - in: query
* name: statut
* schema: { type: string, enum: [en_cours, rembourse, en_retard, procedure, cloture] }
* responses:
* 200: { description: Liste des investissements }
*/
router.get('/', (req, res) => {
const { statut } = req.query;
const conds = ['i.investisseur_id = ?'];
const args = [req.investisseurId];
if (statut) { conds.push('i.statut = ?'); args.push(statut); }
const rows = db.prepare(`
SELECT ${LIST_COLUMNS}
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${conds.join(' AND ')}
ORDER BY i.date_souscription DESC
`).all(...args);
res.json(rows);
});
/**
* @openapi
* /investissements/{id}:
* get:
* summary: Détail d'un investissement, avec ses remboursements réels
* tags: [Investissements]
* security: [{ ApiKeyAuth: [] }]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: Détail de l'investissement }
* 404: { description: Investissement introuvable }
*/
router.get('/:id', (req, res, next) => {
try {
const inv = db.prepare(`
SELECT ${LIST_COLUMNS}, i.notes
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
WHERE i.id = ? AND i.investisseur_id = ?
`).get(req.params.id, req.investisseurId);
if (!inv) throw new HttpError(404, 'Investissement introuvable');
const remboursements = db.prepare(`
SELECT id, date_remb, capital, interets_bruts, prelev_sociaux,
prelev_forfaitaire, cashback, interets_nets, net_recu, statut
FROM remboursements
WHERE investissement_id = ?
ORDER BY date_remb
`).all(inv.id);
res.json({ ...inv, remboursements });
} catch (e) { next(e); }
});
export default router;
+27
View File
@@ -0,0 +1,27 @@
import { Router } from 'express';
import db from '../../db/index.js';
const router = Router();
/**
* @openapi
* /investisseur:
* get:
* summary: Profil de l'investisseur lié à la clé API
* tags: [Investisseur]
* security: [{ ApiKeyAuth: [] }]
* responses:
* 200:
* description: Profil investisseur
* 401:
* description: Clé API invalide ou manquante
*/
router.get('/', (req, res) => {
const inv = db.prepare(`
SELECT id, nom, prenom, type, type_fiscal, notes, created_at
FROM investisseurs WHERE id = ?
`).get(req.investisseurId);
res.json(inv);
});
export default router;
+43
View File
@@ -0,0 +1,43 @@
import { Router } from 'express';
import db from '../../db/index.js';
const router = Router();
/**
* @openapi
* /remboursements:
* get:
* summary: Liste des remboursements réels perçus
* tags: [Remboursements]
* security: [{ ApiKeyAuth: [] }]
* parameters:
* - in: query
* name: date_debut
* schema: { type: string, format: date }
* - in: query
* name: date_fin
* schema: { type: string, format: date }
* responses:
* 200: { description: Liste des remboursements }
*/
router.get('/', (req, res) => {
const { date_debut, date_fin } = req.query;
const conds = ['i.investisseur_id = ?'];
const args = [req.investisseurId];
if (date_debut) { conds.push('r.date_remb >= ?'); args.push(date_debut); }
if (date_fin) { conds.push('r.date_remb <= ?'); args.push(date_fin); }
const rows = db.prepare(`
SELECT r.id, r.date_remb, i.nom_projet, p.nom AS plateforme_nom,
r.capital, r.interets_bruts, r.prelev_sociaux, r.prelev_forfaitaire,
r.cashback, r.interets_nets, r.net_recu, r.statut
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${conds.join(' AND ')}
ORDER BY r.date_remb DESC
`).all(...args);
res.json(rows);
});
export default router;