Implémentation de la feature "suivi des objectifs"

This commit is contained in:
ocroguennec committed 2026-08-17 17:15:50 +02:00
1 parent 6e03195d3a
commit 1060bfe2be
14 files changed
+1844 -192

No files matched your search

+46 -5
View File
@@ -2227,23 +2227,64 @@ console.log('[DB] Migrations 2FA OK');
}
// ── Migration : table objectifs ──────────────────────────────────────────
// Objectifs annuels par investisseur. `type` permet de réutiliser la table
// pour d'autres natures d'objectifs plus tard (ex: 'rendement_annuel') —
// pour l'instant seul 'versement_annuel' (objectif de dépôts nets) est utilisé.
// Objectifs annuels. `type` permet plusieurs natures d'objectifs dans la même
// table : 'versement_annuel' (enveloppe globale de dépôts, par investisseur,
// plateforme_id NULL) et 'capital_investi' (cible de capital investi pour une
// plateforme donnée, par investisseur + plateforme). plateforme_id est NULL
// pour 'versement_annuel', renseigné pour 'capital_investi'. Deux index
// uniques partiels (au lieu d'un seul UNIQUE inline) car SQLite considère
// deux NULL comme distincts : un UNIQUE(investisseur_id,type,annee) global
// n'empêcherait pas plusieurs lignes capital_investi (une par plateforme)
// pour le même investisseur/année — cf. section Paramétrage > Fixation des
// objectifs.
db.exec(`
CREATE TABLE IF NOT EXISTS objectifs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
plateforme_id INTEGER REFERENCES plateformes(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'versement_annuel',
annee INTEGER NOT NULL,
montant REAL NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(investisseur_id, type, annee)
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Migration pour les bases existantes créées avant plateforme_id : recrée la
// table (ALTER TABLE ne permet pas de changer une contrainte UNIQUE inline).
{
const objCols = db.prepare('PRAGMA table_info(objectifs)').all().map(c => c.name);
if (!objCols.includes('plateforme_id')) {
db.exec('PRAGMA foreign_keys = OFF');
db.exec('ALTER TABLE objectifs RENAME TO objectifs_old_migration');
db.exec(`
CREATE TABLE objectifs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
plateforme_id INTEGER REFERENCES plateformes(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'versement_annuel',
annee INTEGER NOT NULL,
montant REAL NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec(`
INSERT INTO objectifs (id, investisseur_id, plateforme_id, type, annee, montant, notes, created_at, updated_at)
SELECT id, investisseur_id, NULL, type, annee, montant, notes, created_at, updated_at
FROM objectifs_old_migration
`);
db.exec('DROP TABLE objectifs_old_migration');
db.exec('PRAGMA foreign_keys = ON');
}
}
db.exec('CREATE INDEX IF NOT EXISTS idx_objectifs_investisseur ON objectifs(investisseur_id, type, annee)');
db.exec('CREATE INDEX IF NOT EXISTS idx_objectifs_plateforme ON objectifs(plateforme_id, type, annee)');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_objectifs_global_uniq ON objectifs(investisseur_id, type, annee) WHERE plateforme_id IS NULL');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_objectifs_plateforme_uniq ON objectifs(plateforme_id, type, annee) WHERE plateforme_id IS NOT NULL');
// ── Migration : suivi anti-doublon des notifications "données incomplètes" ──
// Stocke la signature (liste triée des champs essentiels manquants) au moment
+64 -24
View File
@@ -6,11 +6,22 @@ const router = Router();
const TYPE_DEFAUT = 'versement_annuel';
/** Valide et normalise le corps d'une requête POST */
/**
* Valide et normalise le corps d'une requête POST.
* - type='versement_annuel' (défaut) : objectif global, investisseur_id requis, plateforme_id absent.
* - type='capital_investi' : objectif par plateforme, plateforme_id requis ; investisseur_id est
* dérivé de la plateforme (ignoré s'il est fourni) car une plateforme appartient à un seul
* investisseur — cf. section Paramétrage > Fixation des objectifs.
*/
function parseBody(body) {
const { investisseur_id, type = TYPE_DEFAUT, annee, montant, notes } = body;
if (!investisseur_id) throw new HttpError(400, 'investisseur_id est requis');
const { investisseur_id, plateforme_id, type = TYPE_DEFAUT, annee, montant, notes } = body;
if (!type?.trim()) throw new HttpError(400, 'type est requis');
const trimmedType = type.trim();
if (trimmedType === 'capital_investi') {
if (!plateforme_id) throw new HttpError(400, 'plateforme_id est requis pour un objectif capital_investi');
} else if (!investisseur_id) {
throw new HttpError(400, 'investisseur_id est requis');
}
if (!Number.isInteger(Number(annee)) || Number(annee) < 2000 || Number(annee) > 2100) {
throw new HttpError(400, 'annee invalide');
}
@@ -18,24 +29,26 @@ function parseBody(body) {
throw new HttpError(400, 'montant doit être un nombre positif');
}
return {
investisseur_id: Number(investisseur_id),
type: type.trim(),
investisseur_id: investisseur_id ? Number(investisseur_id) : null,
plateforme_id: plateforme_id ? Number(plateforme_id) : null,
type: trimmedType,
annee: Number(annee),
montant: Number(montant),
notes: notes?.trim() || null,
};
}
/* ── GET /api/objectifs?type=&annee= ─────────────────────────────────────
/* ── GET /api/objectifs?type=&annee=&plateforme_id= ──────────────────────
Retourne tous les objectifs des investisseurs de l'utilisateur connecté
(toutes plateformes / tout le portefeuille — pas de notion de scope=all
vs single ici, un objectif appartient toujours à un investisseur donné). */
router.get('/', (req, res) => {
const { type, annee } = req.query;
const { type, annee, plateforme_id } = req.query;
const conds = ['i.user_id = ?'];
const args = [req.user.id];
if (type) { conds.push('o.type = ?'); args.push(type); }
if (annee) { conds.push('o.annee = ?'); args.push(Number(annee)); }
if (type) { conds.push('o.type = ?'); args.push(type); }
if (annee) { conds.push('o.annee = ?'); args.push(Number(annee)); }
if (plateforme_id) { conds.push('o.plateforme_id = ?'); args.push(Number(plateforme_id)); }
const rows = db.prepare(`
SELECT o.*, i.nom AS investisseur_nom
@@ -47,26 +60,53 @@ router.get('/', (req, res) => {
res.json(rows);
});
/* ── POST /api/objectifs ── upsert (investisseur_id, type, annee) ───────── */
/* ── POST /api/objectifs ── upsert manuel (select puis update/insert) ─────
Pas de ON CONFLICT : deux index uniques partiels différents selon le type
(cf. db/index.js), plus simple et plus sûr de faire l'upsert à la main que
de viser le bon index partiel en SQL. */
router.post('/', (req, res, next) => {
try {
const data = parseBody(req.body);
const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?')
.get(data.investisseur_id, req.user.id);
if (!inv) throw new HttpError(404, 'Investisseur introuvable');
db.prepare(`
INSERT INTO objectifs (investisseur_id, type, annee, montant, notes)
VALUES (?,?,?,?,?)
ON CONFLICT(investisseur_id, type, annee)
DO UPDATE SET montant = excluded.montant, notes = excluded.notes, updated_at = datetime('now')
`).run(data.investisseur_id, data.type, data.annee, data.montant, data.notes);
let investisseurId = data.investisseur_id;
if (data.type === 'capital_investi') {
const plat = db.prepare('SELECT id, investisseur_id FROM plateformes WHERE id = ? AND user_id = ?')
.get(data.plateforme_id, req.user.id);
if (!plat) throw new HttpError(404, 'Plateforme introuvable');
investisseurId = plat.investisseur_id;
} else {
const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?')
.get(investisseurId, req.user.id);
if (!inv) throw new HttpError(404, 'Investisseur introuvable');
}
const saved = db.prepare(`
SELECT o.*, i.nom AS investisseur_nom
FROM objectifs o JOIN investisseurs i ON i.id = o.investisseur_id
WHERE o.investisseur_id = ? AND o.type = ? AND o.annee = ?
`).get(data.investisseur_id, data.type, data.annee);
const existing = data.plateforme_id
? db.prepare('SELECT id FROM objectifs WHERE plateforme_id = ? AND type = ? AND annee = ?')
.get(data.plateforme_id, data.type, data.annee)
: db.prepare('SELECT id FROM objectifs WHERE investisseur_id = ? AND type = ? AND annee = ? AND plateforme_id IS NULL')
.get(investisseurId, data.type, data.annee);
if (existing) {
db.prepare(`UPDATE objectifs SET montant = ?, notes = ?, updated_at = datetime('now') WHERE id = ?`)
.run(data.montant, data.notes, existing.id);
} else {
db.prepare(`
INSERT INTO objectifs (investisseur_id, plateforme_id, type, annee, montant, notes)
VALUES (?,?,?,?,?,?)
`).run(investisseurId, data.plateforme_id, data.type, data.annee, data.montant, data.notes);
}
const saved = data.plateforme_id
? db.prepare(`
SELECT o.*, i.nom AS investisseur_nom
FROM objectifs o JOIN investisseurs i ON i.id = o.investisseur_id
WHERE o.plateforme_id = ? AND o.type = ? AND o.annee = ?
`).get(data.plateforme_id, data.type, data.annee)
: db.prepare(`
SELECT o.*, i.nom AS investisseur_nom
FROM objectifs o JOIN investisseurs i ON i.id = o.investisseur_id
WHERE o.investisseur_id = ? AND o.type = ? AND o.annee = ? AND o.plateforme_id IS NULL
`).get(investisseurId, data.type, data.annee);
res.status(201).json(saved);
} catch (e) { next(e); }
});
+1
View File
@@ -9,6 +9,7 @@ const ALLOWED_KEYS = new Set([
'chart_capital',
'chart_cashback',
'pfo_assujetti',
'objectifs_actifs',
// Extensible : ajouter ici les futures prefs (theme, font_scale, langue, devise, display_mode…)
]);