Correctif Contrainte de suppression de compte

This commit is contained in:
2026-07-05 00:54:40 +02:00
parent 5e775da711
commit 1f75878d87
4 changed files with 83 additions and 2 deletions
+6
View File
@@ -499,6 +499,12 @@ const isBonus = BONUS_VALUES.includes(form.investissement_id);
- **Règle imposée au prompt** : si le fichier ne permet pas d'identifier avec certitude la plateforme (ou l'investissement pour les remboursements), l'IA doit poser la question à l'utilisateur en ne proposant QUE les noms existants comme réponses possibles, et attendre la réponse avant de générer le JSON — plutôt que de deviner ou d'omettre le champ silencieusement - **Règle imposée au prompt** : si le fichier ne permet pas d'identifier avec certitude la plateforme (ou l'investissement pour les remboursements), l'IA doit poser la question à l'utilisateur en ne proposant QUE les noms existants comme réponses possibles, et attendre la réponse avant de générer le JSON — plutôt que de deviner ou d'omettre le champ silencieusement
- Page restructurée en 3 blocs séquentiels : "1. Contexte de l'import" (sélecteur de module) → "2. Fichier source" → "3. Mappage des colonnes" ; le bloc "Dossier investissement" n'apparaît que si le module actif est `investissements` - Page restructurée en 3 blocs séquentiels : "1. Contexte de l'import" (sélecteur de module) → "2. Fichier source" → "3. Mappage des colonnes" ; le bloc "Dossier investissement" n'apparaît que si le module actif est `investissements`
### Bug — suppression de compte cassée par plateforme_id ON DELETE RESTRICT
- **Symptôme** : `DELETE /api/auth/me` échouait avec `SqliteError: FOREIGN KEY constraint failed` (code `SQLITE_CONSTRAINT_TRIGGER`) à `auth.js:319`.
- **Cause** : `depots_retraits.plateforme_id` et `investissements.plateforme_id` étaient en `ON DELETE RESTRICT` (seuls FK RESTRICT du schéma) — lors du `DELETE FROM users`, les branches de cascade `users→plateformes` et `users→investisseurs→depots_retraits/investissements` sont indépendantes ; SQLite peut supprimer la plateforme avant les lignes qui la référencent encore, déclenchant RESTRICT.
- **Fix** : migration `fixPlateformeCascade` dans `db/index.js` (recréation table via `__repair_*`, RESTRICT→CASCADE) + `schema.sql` mis à jour + protection déplacée côté application dans `DELETE /api/plateformes/:id` (vérification explicite du nombre d'investissements/dépôts-retraits avant suppression, message clair).
- **Règle à retenir** : toute nouvelle table référençant `plateformes`/`investisseurs`/`users` doit être en `CASCADE` (ou `SET NULL`), jamais `RESTRICT` — sinon la suppression de compte se recasse. Les protections anti-suppression-accidentelle doivent être implémentées côté route, pas côté contrainte FK.
### Piège outillage — mount bash périmé après édition Edit/Write ### Piège outillage — mount bash périmé après édition Edit/Write
- Après une édition via l'outil `Edit`/`Write` (côté fichier réel), une vérification immédiate via `mcp__workspace__bash` (wc/tail/node --check) peut montrer une **version périmée/tronquée** du fichier alors que le fichier réel est complet et correct — le mount bash ne se resynchronise pas instantanément après une écriture Windows-side. - Après une édition via l'outil `Edit`/`Write` (côté fichier réel), une vérification immédiate via `mcp__workspace__bash` (wc/tail/node --check) peut montrer une **version périmée/tronquée** du fichier alors que le fichier réel est complet et correct — le mount bash ne se resynchronise pas instantanément après une écriture Windows-side.
- **Règle** : vérifier l'intégrité d'un fichier édité via l'outil `Read` (relire la queue, vérifier la fermeture propre), pas via bash. N'utiliser bash pour vérifier que si l'écriture a elle-même été faite depuis bash (ex. reconstruction `python3` après troncature confirmée par `Read`). - **Règle** : vérifier l'intégrité d'un fichier édité via l'outil `Read` (relire la queue, vérifier la fermeture propre), pas via bash. N'utiliser bash pour vérifier que si l'écriture a elle-même été faite depuis bash (ex. reconstruction `python3` après troncature confirmée par `Read`).
+65
View File
@@ -2086,4 +2086,69 @@ console.log('[DB] Migrations 2FA OK');
} }
} }
// ── Migration : plateforme_id en CASCADE (au lieu de RESTRICT) sur
// depots_retraits et investissements ────────────────────────────────────
// RESTRICT empêchait la suppression complète d'un compte (DELETE FROM users) :
// la cascade users→plateformes (user_id CASCADE) et users→investisseurs→
// depots_retraits/investissements (CASCADE) sont deux branches indépendantes
// de l'arbre de suppression ; SQLite peut supprimer une plateforme avant les
// lignes qui la référencent encore sur l'autre branche, ce qui déclenche le
// RESTRICT (erreur SQLITE_CONSTRAINT_TRIGGER). La protection "impossible de
// supprimer une plateforme qui a encore des données" est déplacée dans la
// route DELETE /api/plateformes/:id (vérification explicite avec message clair).
{
const fixPlateformeCascade = (tableName, indexStatements) => {
const row = db.prepare(
`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`
).get(tableName);
if (!row || !/REFERENCES\s+plateformes\(id\)\s+ON DELETE RESTRICT/i.test(row.sql)) return;
const tempName = `__repair_${tableName}`;
const nameRe = new RegExp(
`CREATE TABLE\\s+(?:IF NOT EXISTS\\s+)?["'\`\\[]?${tableName}["'\`\\]]?`, 'i'
);
if (!nameRe.test(row.sql)) {
console.error(`[DB] migration plateforme_id CASCADE : nom de table non reconnu dans le DDL de "${tableName}", migration ignorée.`);
return;
}
const fixedDdl = row.sql
.replace(nameRe, `CREATE TABLE "${tempName}"`)
.replace(/REFERENCES\s+plateformes\(id\)\s+ON DELETE RESTRICT/i, 'REFERENCES plateformes(id) ON DELETE CASCADE');
const colDefs = db.prepare(`PRAGMA table_info("${tableName}")`).all();
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
const idxs = db.prepare(
`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name=? AND sql IS NOT NULL`
).all(tableName);
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
db.exec(fixedDdl);
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM "${tableName}"`);
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
db.exec(`DROP TABLE "${tableName}"`);
db.exec('PRAGMA legacy_alter_table = ON');
db.exec(`ALTER TABLE "${tempName}" RENAME TO "${tableName}"`);
db.exec('PRAGMA legacy_alter_table = OFF');
db.exec('PRAGMA foreign_keys = ON');
for (const stmt of indexStatements) db.exec(stmt);
console.log(`[DB] migration : plateforme_id passé en ON DELETE CASCADE sur "${tableName}".`);
};
fixPlateformeCascade('depots_retraits', [
'CREATE INDEX IF NOT EXISTS idx_depret_inv ON depots_retraits(investisseur_id)',
'CREATE INDEX IF NOT EXISTS idx_depret_plat ON depots_retraits(plateforme_id)',
'CREATE INDEX IF NOT EXISTS idx_depret_date ON depots_retraits(date_operation)',
]);
fixPlateformeCascade('investissements', [
'CREATE INDEX IF NOT EXISTS idx_inv_inv ON investissements(investisseur_id)',
'CREATE INDEX IF NOT EXISTS idx_inv_plat ON investissements(plateforme_id)',
'CREATE INDEX IF NOT EXISTS idx_inv_statut ON investissements(statut)',
'CREATE INDEX IF NOT EXISTS idx_inv_date ON investissements(date_souscription)',
]);
}
export default db; export default db;
+2 -2
View File
@@ -70,7 +70,7 @@ CREATE INDEX IF NOT EXISTS idx_plateformes_user ON plateformes(user_id);
CREATE TABLE IF NOT EXISTS depots_retraits ( CREATE TABLE IF NOT EXISTS depots_retraits (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE, investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE RESTRICT, plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
date_operation TEXT NOT NULL, -- ISO YYYY-MM-DD date_operation TEXT NOT NULL, -- ISO YYYY-MM-DD
type TEXT NOT NULL CHECK(type IN ('depot','retrait')), type TEXT NOT NULL CHECK(type IN ('depot','retrait')),
montant REAL NOT NULL CHECK(montant >= 0), montant REAL NOT NULL CHECK(montant >= 0),
@@ -92,7 +92,7 @@ CREATE INDEX IF NOT EXISTS idx_depret_date ON depots_retraits(date_operation);
CREATE TABLE IF NOT EXISTS investissements ( CREATE TABLE IF NOT EXISTS investissements (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE, investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE RESTRICT, plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
nom_projet TEXT NOT NULL, nom_projet TEXT NOT NULL,
emetteur TEXT, -- nom de la société emprunteuse emetteur TEXT, -- nom de la société emprunteuse
date_souscription TEXT NOT NULL, date_souscription TEXT NOT NULL,
+10
View File
@@ -679,6 +679,16 @@ router.delete('/:id', (req, res, next) => {
const plat = db.prepare('SELECT logo_filename FROM plateformes WHERE id=? AND user_id=?') const plat = db.prepare('SELECT logo_filename FROM plateformes WHERE id=? AND user_id=?')
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!plat) throw new HttpError(404, 'Not found'); if (!plat) throw new HttpError(404, 'Not found');
// plateforme_id est en ON DELETE CASCADE sur investissements/depots_retraits (nécessaire pour
// permettre la suppression complète d'un compte) — on protège donc ici, explicitement, contre
// la suppression accidentelle d'une plateforme qui a encore des données rattachées.
const { n: nbInv } = db.prepare('SELECT COUNT(*) AS n FROM investissements WHERE plateforme_id = ?').get(req.params.id);
const { n: nbDr } = db.prepare('SELECT COUNT(*) AS n FROM depots_retraits WHERE plateforme_id = ?').get(req.params.id);
if (nbInv > 0 || nbDr > 0) {
throw new HttpError(400, `Impossible de supprimer cette plateforme : elle a encore ${nbInv} investissement(s) et ${nbDr} mouvement(s) de dépôt/retrait enregistrés. Supprimez-les d'abord.`);
}
const r = db.prepare('DELETE FROM plateformes WHERE id=? AND user_id=?') const r = db.prepare('DELETE FROM plateformes WHERE id=? AND user_id=?')
.run(req.params.id, req.user.id); .run(req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found'); if (r.changes === 0) throw new HttpError(404, 'Not found');