From 1f75878d87c6ae3eaa878e51abdb804c0cc00281 Mon Sep 17 00:00:00 2001 From: Olivier Date: Sun, 5 Jul 2026 00:54:40 +0200 Subject: [PATCH] Correctif Contrainte de suppression de compte --- MEMORY.md | 6 +++ backend/src/db/index.js | 65 +++++++++++++++++++++++++++++++ backend/src/db/schema.sql | 4 +- backend/src/routes/plateformes.js | 10 +++++ 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index a932f6e..d9f0050 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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 - 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 - 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`). diff --git a/backend/src/db/index.js b/backend/src/db/index.js index e0c873b..6ade72d 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -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; diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 3cb7c5c..15e335a 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -70,7 +70,7 @@ CREATE INDEX IF NOT EXISTS idx_plateformes_user ON plateformes(user_id); CREATE TABLE IF NOT EXISTS depots_retraits ( id INTEGER PRIMARY KEY, 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 type TEXT NOT NULL CHECK(type IN ('depot','retrait')), 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 ( id INTEGER PRIMARY KEY, 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, emetteur TEXT, -- nom de la société emprunteuse date_souscription TEXT NOT NULL, diff --git a/backend/src/routes/plateformes.js b/backend/src/routes/plateformes.js index 1826901..f4b452a 100644 --- a/backend/src/routes/plateformes.js +++ b/backend/src/routes/plateformes.js @@ -679,6 +679,16 @@ router.delete('/:id', (req, res, next) => { const plat = db.prepare('SELECT logo_filename FROM plateformes WHERE id=? AND user_id=?') .get(req.params.id, req.user.id); 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=?') .run(req.params.id, req.user.id); if (r.changes === 0) throw new HttpError(404, 'Not found');