diff --git a/CHANGELOG.md b/CHANGELOG.md index f0d42bf..af83543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog - FlowDeck +## v7.77.0 (2026-10-22) — Corbeille réparée + MCP : les workspaces + +### Corrigé + +- **La suppression définitive depuis la corbeille échouait (500)** + pour toute page réelle : `favorites.page_id` et `recents.page_id` + référencent `pages(id)` sans CASCADE, avec les clés étrangères + actives — un `IntegrityError` dès que la page avait été ouverte + ou mise en favori. La purge automatique des 30 jours mourait sur + la même erreur, en silence. Nouveau nettoyage partagé + `detach_page_references`, appliqué aux trois chemins (suppression + unitaire, Empty Trash, purge planifiée). Cas réel de Bruno : + 4 pages impossibles à supprimer. Test de régression ajouté. + +### Ajouté + +- **MCP : `list_workspaces` et `get_workspace`** — les workspaces + (conteneurs de pages : Techno, Famille Charest…) sont enfin + exposés au service d'agents, et les descriptions des outils + collections disent désormais clairement qu'une collection est une + base de données, pas un workspace (la réponse « 17 collections + (espaces de travail) » du panneau a induit Bruno en erreur). + ## v7.76.1 (2026-10-22) — Correctif : le MCP était bloqué par le CSRF ### Corrigé diff --git a/VERSION b/VERSION index 4fdb6e9..dd22412 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.76.1 +7.77.0 diff --git a/app/routers/board/sharing.py b/app/routers/board/sharing.py index 369bcfd..5b8e5c5 100644 --- a/app/routers/board/sharing.py +++ b/app/routers/board/sharing.py @@ -16,6 +16,7 @@ from app.services.collection_lifecycle import ( collections_hosted_by_page, collections_hosted_by_pages, delete_collections, + detach_page_references, ) from app.services.publish import fire_published, fire_unpublished, publish, unpublish @@ -199,6 +200,7 @@ def permanent_delete(request: Request, page_id: int): # tâches sans document. collections = collections_hosted_by_page(conn, page_id) conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,)) + detach_page_references(conn, [page_id]) conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,)) delete_collections(conn, collections) conn.commit() @@ -229,6 +231,7 @@ def empty_trash(request: Request): "UPDATE pages SET parent_id=NULL WHERE parent_id IN " "(SELECT id FROM pages WHERE deleted_at IS NOT NULL)" ) + detach_page_references(conn, trashed) conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL") delete_collections(conn, collections) conn.commit() diff --git a/app/routers/mcp_server.py b/app/routers/mcp_server.py index 521ab23..b4f7a57 100644 --- a/app/routers/mcp_server.py +++ b/app/routers/mcp_server.py @@ -10,9 +10,11 @@ Authentification : les jetons d'API personnels existants Le service d'agents reçoit un jeton dédié créé par l'utilisateur ; il lit et écrit en son nom. -Objet exposé : les **collections** (les projets/espaces de travail -de Flowdeck) et leurs pages, la recherche par titre, et le registre -des dépôts (table ``projects``). Doctrine du programme : Flowdeck +Objets exposés : les **workspaces** (conteneurs de pages) et leurs +pages, les **collections** (bases de données type Notion — ne pas +confondre : une collection vit dans un workspace ou une page), la +recherche par titre, et le registre des dépôts (table ``projects``). +Doctrine du programme : Flowdeck référence les tâches, il ne les copie pas — aucune tâche TikTak ne transite par ici. Écritures limitées à la création/mise à jour de pages et à la création de collections ; aucune suppression. @@ -30,9 +32,24 @@ from app.routers.public_api import _token_owner router = APIRouter(tags=["mcp"]) OUTILS = [ + {"name": "list_workspaces", + "description": "Liste les WORKSPACES Flowdeck — les conteneurs de pages " + "de l'utilisateur (ex. Techno, Famille Charest), avec leur " + "nombre de pages actives. À ne pas confondre avec les " + "collections (bases de données).", + "inputSchema": {"type": "object", "properties": {}}}, + {"name": "get_workspace", + "description": "Lit un workspace et l'arborescence de ses pages " + "(titres, parent, section) — pages classiques, pas les " + "lignes des collections.", + "inputSchema": {"type": "object", + "properties": {"id": {"type": "integer"}}, + "required": ["id"]}}, {"name": "list_collections", - "description": "Liste les collections Flowdeck (projets / espaces de travail) " - "avec leur nombre de pages.", + "description": "Liste les COLLECTIONS Flowdeck — les bases de données " + "de type Notion (tables, CRM, calendriers, formulaires), " + "avec leur nombre de lignes. Ce ne sont PAS les workspaces : " + "une collection vit dans un workspace ou dans une page.", "inputSchema": {"type": "object", "properties": {}}}, {"name": "get_collection", "description": "Lit une collection et ses pages (titres et propriétés).", @@ -97,6 +114,25 @@ def _page_resume(row) -> dict: def _appel_outil(nom: str, args: dict): with get_conn() as conn: + if nom == "list_workspaces": + rows = conn.execute( + "SELECT w.id, w.name, w.owner_id, " + "(SELECT COUNT(*) FROM pages p WHERE p.workspace_id = w.id " + " AND p.deleted_at IS NULL) AS page_count " + "FROM workspaces w ORDER BY w.name").fetchall() + return [dict(r) for r in rows] + if nom == "get_workspace": + ws = conn.execute("SELECT * FROM workspaces WHERE id = ?", + (int(args.get("id", 0)),)).fetchone() + if ws is None: + return {"erreur": "workspace introuvable"} + pages = conn.execute( + "SELECT id, title, parent_id, parent_section, updated_at " + "FROM pages WHERE workspace_id = ? AND deleted_at IS NULL " + "ORDER BY parent_id, title LIMIT 300", (ws["id"],)).fetchall() + return {"workspace": {"id": ws["id"], "name": ws["name"], + "owner_id": ws["owner_id"]}, + "pages": [dict(p) for p in pages]} if nom == "list_collections": rows = conn.execute( "SELECT c.*, (SELECT COUNT(*) FROM collection_pages p " diff --git a/app/services/collection_lifecycle.py b/app/services/collection_lifecycle.py index edf8773..ea70418 100644 --- a/app/services/collection_lifecycle.py +++ b/app/services/collection_lifecycle.py @@ -51,6 +51,26 @@ EFFECTIVE_WORKSPACE_SQL = ( ) +def detach_page_references(conn, page_ids) -> None: + """Supprime les lignes qui référencent des pages en ``NO ACTION``. + + ``favorites.page_id`` et ``recents.page_id`` référencent + ``pages(id)`` sans CASCADE et la connexion applique + ``PRAGMA foreign_keys=ON`` : supprimer définitivement une page + réelle — ouverte un jour (recents) ou mise en favori — levait un + ``IntegrityError`` (500 côté API, échec silencieux de la purge + automatique des 30 jours). À appeler avant tout + ``DELETE FROM pages`` définitif. (Régression du 2026-10-22 : + les pages synthétiques des tests n'avaient ni favori ni récent.) + """ + ids = [int(i) for i in page_ids] + if not ids: + return + ph = ",".join("?" * len(ids)) + conn.execute(f"DELETE FROM favorites WHERE page_id IN ({ph})", ids) + conn.execute(f"DELETE FROM recents WHERE page_id IN ({ph})", ids) + + def effective_workspace_sql(alias: str = "c") -> str: """Workspace effectif d'une collection (colonne ou ``NULL`` si orpheline).""" return EFFECTIVE_WORKSPACE_SQL.format(a=alias) diff --git a/app/services/trash.py b/app/services/trash.py index 739489f..af759ca 100644 --- a/app/services/trash.py +++ b/app/services/trash.py @@ -16,6 +16,7 @@ from app.db import get_conn from app.services.collection_lifecycle import ( collections_hosted_by_page, delete_collections, + detach_page_references, ) logger = logging.getLogger(__name__) @@ -71,6 +72,7 @@ def purge_expired(days: int = 30) -> dict: "WHERE parent_id=?", (page_id, page_id), ) + detach_page_references(conn, [page_id]) conn.execute("DELETE FROM pages WHERE id=?", (page_id,)) delete_collections(conn, collections) purged.append(page_id) diff --git a/tests/test_fusion_mcp_server.py b/tests/test_fusion_mcp_server.py index e73dda4..3b3c211 100644 --- a/tests/test_fusion_mcp_server.py +++ b/tests/test_fusion_mcp_server.py @@ -68,11 +68,35 @@ def test_mcp_handshake_et_outils(client, jeton): assert r.json()["result"]["serverInfo"]["name"] == "flowdeck" r = _rpc(client, jeton, 2, "tools/list") noms = [t["name"] for t in r.json()["result"]["tools"]] - assert noms == ["list_collections", "get_collection", "get_page", + assert noms == ["list_workspaces", "get_workspace", + "list_collections", "get_collection", "get_page", "search_pages", "list_repos", "create_page", "update_page", "create_collection"] +def test_mcp_workspaces(client, jeton): + with get_conn() as conn: + conn.execute("INSERT OR IGNORE INTO users (id, login) VALUES (1, 'tester')") + cur = conn.execute( + "INSERT INTO workspaces (name, owner_id) VALUES ('Techno', 1)") + wid = cur.lastrowid + conn.execute( + "INSERT INTO pages (workspace, workspace_id, title, parent_section) " + "VALUES ('Techno', ?, 'Page racine', 'Private')", (wid,)) + conn.execute( + "INSERT INTO pages (workspace, workspace_id, title, parent_section, deleted_at) " + "VALUES ('Techno', ?, 'Page corbeille', 'Private', '2026-10-11 10:00:00')", (wid,)) + conn.commit() + ws = _appel(client, jeton, 70, "list_workspaces") + techno = [w for w in ws if w["name"] == "Techno"] + assert techno and techno[0]["page_count"] == 1 # la corbeille ne compte pas + detail = _appel(client, jeton, 71, "get_workspace", {"id": wid}) + assert detail["workspace"]["name"] == "Techno" + assert [p["title"] for p in detail["pages"]] == ["Page racine"] + res = _appel(client, jeton, 72, "get_workspace", {"id": 99999}) + assert "introuvable" in res["erreur"] + + def test_mcp_collections_et_pages(client, jeton, donnees): cols = _appel(client, jeton, 1, "list_collections") alpha = [c for c in cols if c["name"] == "Projet Alpha"] diff --git a/tests/test_trash_api.py b/tests/test_trash_api.py index f932538..0620540 100644 --- a/tests/test_trash_api.py +++ b/tests/test_trash_api.py @@ -32,6 +32,32 @@ def _row(page_id): ).fetchone() +def test_permanent_delete_page_referenced_by_favorites_and_recents(client): + """Régression 7.77.0 (cas réel de Bruno, 2026-10-22) : une page + réelle — ouverte (recents) et/ou favorite — ne pouvait plus être + supprimée définitivement : favorites.page_id et recents.page_id + référencent pages(id) sans CASCADE, PRAGMA foreign_keys=ON → + IntegrityError → 500 « Delete failed », et Empty Trash (une seule + transaction) échouait en bloc. Les pages synthétiques des anciens + tests n'avaient ni favori ni récent : elles passaient.""" + from app.db import get_conn + + login_test_client(client) + pid = _mk_page(title="Page réelle de Bruno") + with get_conn() as conn: + conn.execute("INSERT INTO favorites (user_id, page_id, position) VALUES (1, ?, 0)", (pid,)) + conn.execute("INSERT INTO recents (user_id, page_id, workspace) VALUES (1, ?, 'x')", (pid,)) + conn.commit() + r = client.post(f"/api/pages/{pid}/trash") + assert r.status_code == 200 + r = client.delete(f"/board/api/trash/{pid}") + assert r.status_code == 200, r.text + assert _row(pid) is None + with get_conn() as conn: + assert conn.execute("SELECT COUNT(*) FROM favorites WHERE page_id=?", (pid,)).fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM recents WHERE page_id=?", (pid,)).fetchone()[0] == 0 + + def test_trash_requires_session(client): pid = _mk_page() anon_csrf(client) # anonyme mais CSRF valide → on veut le 401, pas le 403