# FlowDeck — Modèle de données (v7.69.8) Document de référence du schéma de persistance, généré à partir du code réel : - `app/db.py` — schéma de base (baseline, « version 1 »), créé de façon idempotente par `init_db()` (lignes 14–839) et connexion SQLite via `get_conn()` (lignes 843–858) ; - `app/migrations.py` — migrations versionnées (1–38, table `schema_version`) ; - `app/services/webhook_outbound.py` — table `webhook_subscriptions` (créée au boot, l. 272–286) ; - `app/models/requests.py` / `app/models/responses.py` — couche Pydantic de l'API. Total : **105 tables relationnelles + 1 table virtuelle FTS5** (`pages_fts`), réparties entre le baseline (`app/db.py`) et 33 migrations effectuant des créations de tables (`app/migrations.py`). --- ## 1. Moteur de base de données ### 1.1 SQLite uniquement (pas de dual-engine) **Correction vis-à-vis de certaines notes d'architecture : FlowDeck ne supporte PAS PostgreSQL.** Le code est exclusivement SQLite : - `app/db.py` l.4 : `import sqlite3` (stdlib) — aucune dépendance `psycopg`/`pg8000`/SQLAlchemy nulle part (`requirements.txt`, `pyproject.toml` vérifiés) ; - le module entier est typé `sqlite3.Connection` (y compris `app/migrations.py` l.16) ; - fonctionnalités SQLite spécifiques utilisées : `PRAGMA journal_mode=WAL`, `PRAGMA foreign_keys=ON`, `PRAGMA busy_timeout=5000` (`get_conn`, l.846–850), `INTEGER PRIMARY KEY AUTOINCREMENT`, `ALTER TABLE … ADD COLUMN` (sans `IF NOT EXISTS`, d'où le pattern `try/except sqlite3.OperationalError`), déclencheurs (`TRIGGER`), extension **FTS5**, `DROP TABLE`/`RENAME` pour les reconstructions (`comments`, `tags`) ; - `app/config.py` l.58 : `database_url: str = "sqlite:////data/flowdeck.db"` — seul le préfixe `sqlite:///` est interprété par la propriété `db_path` (l.151–163, avec gestion `:memory:` et des chemins Windows type `C:\…`) ; toute autre valeur retombe sur `/data/flowdeck.db`. La « compatibilité » mentionnée dans certains documents est donc en réalité : une **base SQLite unique par instance**, en mode WAL (un writer, multi-readers), avec `PRAGMA busy_timeout=5000` pour supporter la concurrence (tests xdist, scheduler d'automations). Les backups (v5.2.0, `backup_*` dans `app/config.py`) sont des copies du fichier SQLite. ### 1.2 Stratégie de migrations Décrite dans l'en-tête de `app/migrations.py` (l.1–13) : | Composant | Rôle | |---|---| | `app/db.py::init_db()` | Schéma **baseline** (« version 1 ») : `CREATE TABLE IF NOT EXISTS` + ALTER garde-fous (`try/except OperationalError`). Réexécuté à chaque boot, idempotent. | | `schema_version` (`version INTEGER PK, name TEXT, applied_at`) | Registre des migrations appliquées ; version maximale lue par `current_version()` (l.66–71). Scellée à `BASELINE_VERSION = 1` au premier démarrage (l.86–101). | | `@register(version, name)` (l.28–38) | Décorateur d'enregistrement dans `MIGRATIONS`, trié par version, doublons refusés. | | `apply_migrations(conn)` (l.86–111) | Appelée en fin de `init_db()` (`app/db.py` l.829–831) ; applique exactement une fois chaque migration `> version courante`. | | `_apply_one()` (l.113–130) | **Une migration = une transaction** (`BEGIN` explicite, `rollback` complet à l'échec, puis `INSERT INTO schema_version`) — DDL tout-ou-rien (audit A31). | | `columns(conn, table)` (l.53–64) | Helper unique `PRAGMA table_info` pour tester l'existence de colonnes avant un ALTER. | | `fts5_available()` (l.74–83) | Détection de FTS5 ; sinon repli sur recherche `LIKE`. | Version de schéma actuelle : **38** (`v7.1.1 meeting block`, dernier `@register`). Les migrations 4 et 17 et 23–38 ont été enregistrées hors ordre chronologique de version (versions 4, 17, 23 insérées après 21) — le tri à l'enregistrement garantit l'ordre d'application. --- ## 2. Tables par domaine Légende : **PK** = clé primaire, **FK** = clé étrangère (avec cascade quand indiquée). Sauf mention, PK = `id INTEGER PK AUTOINCREMENT`. ### 2.1 Kanban & intégration Gitea (héritage v0.x–v1.x) Créées dans `app/db.py` (l.22–140, 430–478). | Table | But | Clés / colonnes clés | |---|---|---| | `boards` | Board Kanban par dépôt Gitea | `UNIQUE(project_owner, project_name)` ; `columns_json` (liste des colonnes), `wip_limits_json` | | `cards` | Position d'une issue Gitea dans une colonne | FK `board_id → boards` ; `gitea_issue_id`, `column_name`, `position`, `priority`, `due_date` | | `col_mapping` | Mapping colonne ↔ label Gitea | FK `board_id → boards` ; `UNIQUE(board_id, column_name)` ; `close_issue` | | `notes` | Notes par projet | `UNIQUE(project_owner, project_name, title)` ; `content` | | `checklists` | Checklists par issue | `board_id`, `gitea_issue_id` (pas de FK déclarée), `title`, `position`, `UNIQUE(board_id, gitea_issue_id, title)` | | `checklist_items` | Lignes de checklist | FK `checklist_id → checklists ON DELETE CASCADE` ; `content`, `checked`, `position` | | `project_properties` | Propriétés par projet (ancêtre de `collection_properties`, conservé) | `UNIQUE(project_owner, project_name, name)` ; `prop_type`, `options_json` | | `property_values` | Valeurs de ces propriétés par issue | FK `property_id → project_properties ON DELETE CASCADE` ; `UNIQUE(property_id, gitea_issue_id)` | | `ai_keywords` | Fréquentité des mots-clés détectés par l'IA | `UNIQUE(project_owner, project_name, keyword)` ; `color`, `usage_count` | | `gitea_private_pages` | Pages privées rattachées à un dépôt Gitea (v2.7) | FK `user_id → users` ; `gitea_owner`, `gitea_repo`, `content` | | `user_tokens` | Token Gitea par utilisateur | `gitea_user_id INTEGER UNIQUE`, `gitea_token` | | `projects` | Registre forge-agnostique des projets (gitea/github/builtin), synchronisé par cron (mig. 6, `migrations.py` l.260–331) | `UNIQUE(proj_type, owner, name)` ; `clone_url`, `default_branch`, `last_synced_at` | ### 2.2 Utilisateurs & authentification | Table | But | Clés / colonnes clés | Emplacement | |---|---|---|---| | `users` | Compte local ou fédéré | `login UNIQUE`, `email`, `password_hash`, `is_admin`, `is_active`, `login_attempts`, `locked_until`, `auth_method` (local\|gitea), `avatar_url/color`, `sidebar_config` (JSON), `notification_prefs` (JSON), `timezone`, `totp_secret_enc`, `totp_backup_hashes` (JSON) | db.py l.22 ; ALTER db.py l.328–470, mig. 10/11/28 | | `login_history` | Journal des connexions | FK `user_id → users` ; `ip_address`, `user_agent` | db.py l.40 | | `user_oauth_tokens` | Tokens OAuth par provider (Gitea, Google, M365…) | FK `user_id → users` ; `UNIQUE(user_id, provider)` ; `access_token`, `refresh_token`, `expires_at` | db.py l.55 | | `user_sessions` | Sessions cookie révocables (une ligne = un cookie signé) | **PK `id TEXT`** (payload du cookie) ; FK `user_id → users ON DELETE CASCADE` ; `revoked`, `last_seen_at` | mig. 6 (l.294–316) | | `api_tokens` | Tokens Bearer publics (sha256 stocké) | FK `user_id → users ON DELETE CASCADE` ; `token_hash UNIQUE`, `token_prefix`, `scopes`, `expires_at`, `revoked` | mig. 6 + mig. 20 (l.884–900) | | `webauthn_credentials` | Passkeys / 2FA WebAuthn | FK `user_id → users ON DELETE CASCADE` ; `credential_id UNIQUE`, `public_key`, `sign_count` | mig. 28 (l.1311–1324) | | `sso_config` | Config SSO entreprise (SAML 2.0 ou OIDC), la table prime sur `.env` dès qu'un admin sauvegarde | FK `workspace_id → workspaces` ; secrets chiffrés au repos (`client_secret`, `sp_private_key` — `app/services/sso_provisioning.py`) ; `attribute_mapping`/`groups_mapping` (JSON), `active` | mig. 23 (l.1495–1523) | | `sso_login_history` | Audit de chaque tentative SSO (succès + rejets) | FK `user_id → users` ; `success`, `error_message`, `sso_identifier` | mig. 23 (l.1525–1545) | | `sso_requests` | Store mono-usage anti-replay (AuthnRequest ID, état OIDC, PKCE, relay state) | **PK `id TEXT`** ; `kind`, `code_verifier`, `next_path`, `used` | mig. 23 (l.1547–1555) | | `scim_tokens` | Tokens Bearer de provisioning SCIM (hash unique) | `token_hash UNIQUE`, `revoked`, FK `created_by → users` | mig. 28 (l.1285–1294) | | `domain_claims` | Domaines d'entreprise vérifiés (DNS/txt), auto-jonction + `enforce_sso` | `domain UNIQUE`, `txt_token`, FK `workspace_id → workspaces` | mig. 28 (l.1296–1307) | | `llm_config` | Config LLM globale de l'instance — **ligne unique** `CHECK (id = 1)` | `provider`, `model`, `api_key`, `api_base`, `verified`, `verified_model`, `last_error` ; précédence : ligne DB > `settings.llm_*` | db.py l.769–784 | | `user_llm_keys` | Clés API LLM par utilisateur et provider | FK `user_id → users ON DELETE CASCADE` ; `UNIQUE(user_id, provider)` ; `models_json` (cache des modèles), `default_model`, `verified*` | db.py l.788–812 | ### 2.3 Workspaces, collaboration & permissions | Table | But | Clés / colonnes clés | Emplacement | |---|---|---|---| | `workspaces` | Espace de travail multi-utilisateur | FK `owner_id → users` ; `settings_json` | db.py l.222 | | `workspace_members` | Adhésion + rôle (`owner/admin/editor/viewer/commenter`) | FK `workspace_id → workspaces CASCADE`, `user_id → users` ; `UNIQUE(workspace_id, user_id)` | db.py l.230 | | `teamspaces` | Namespace de pages/bases dans un workspace (v7.3.0) ; `private=1` = invisibilité 404 | FK `workspace_id → workspaces CASCADE` ; `UNIQUE(workspace_id, name)` | mig. 29 (l.1380–1391) | | `teamspace_members` | Membres + rôle d'un teamspace | FK `teamspace_id → teamspaces CASCADE`, `user_id → users` ; `UNIQUE(teamspace_id, user_id)` | mig. 29 (l.1393–1403) | | `user_groups` | Groupes réutilisables par workspace (ACL) | FK `workspace_id → workspaces CASCADE` ; `UNIQUE(workspace_id, name)` | mig. 18 (l.721–730) | | `group_members` | Adhésion à un groupe | FK `group_id → user_groups CASCADE`, `user_id → users CASCADE` ; `UNIQUE(group_id, user_id)` | mig. 18 (l.732–740) | | `page_permissions` | Grant explicite sur une page de l'éditeur | FK `page_id → pages CASCADE` ; `user_id XOR group_id` (`CHECK`) ; `role` (viewer/commenter/editor/owner), `grant_type`, `granted_by` | mig. 18 (l.745–760) | | `collection_permissions` | Idem pour les bases (collections) | FK `collection_id → collections CASCADE` ; mêmes colonnes | mig. 18 (l.766–780) | | `property_permissions` | Grant par propriété de collection (view/edit) | FK `collection_id`, `property_id → collection_properties CASCADE` | mig. 18 (l.782–797) | | `permission_audit_log` | Traînée immuable grant/revoke | `resource_type`, `resource_id`, `action`, `old_role`, `new_role`, `performed_by → users`, `ip_address` | mig. 18 (l.799–815) | | `page_shares` | Partage de page (user, groupe ou email) | FK `page_id → pages CASCADE`, `shared_with_user_id → users`, `shared_with_group_id → user_groups` ; `permission` (view/edit/comment) | db.py l.451–478 | | `guest_shares` | Accès sans compte via `/g/` (v7.3.0) | FK `page_id → pages CASCADE` ; `token UNIQUE`, `role`, `expires_at`, `revoked` | mig. 29 (l.1440–1452) | | `favorites` | Favoris d'un utilisateur (reconstruite en v2.2 pour pointer `pages`) | FK `user_id → users`, `page_id → pages` ; `UNIQUE(user_id, page_id)` | db.py l.260–270 | | `recents` | Pages récemment accédées (vue « Recents ») | FK `user_id → users`, `page_id → pages` ; `source_type` (local/gitea…), `UNIQUE(user_id, page_id)` | db.py l.481–493 | | `notifications` | Notifications in-app (mention/commentaire/page) | FK `user_id → users CASCADE`, `actor_id → users` ; `ntype`, `resource_type/resource_id`, `is_read` ; index `(user_id, is_read)` | db.py l.592–613 | | `comments` | Commentaires (fil, résolus) ancrés sur pages ou lignes de base — **reconstruite** v4.9 : cible polymorphe | FK `user_id → users` ; `target_type` (`page` \| `collection_page`) + `target_id`, `parent_id` (réponses), `anchor_block_id/anchor_start/anchor_end` (ancre inline) | db.py l.241 (v2.0) puis l.620–649 (v4.9) | | `comment_reactions` | Réactions emoji d'un commentaire | FK `comment_id → comments CASCADE`, `user_id → users CASCADE` ; `UNIQUE(comment_id, user_id, emoji)` | mig. 29 (l.1418–1428) | | `text_reactions` | Réactions ancrées sur une plage de texte d'un bloc (façon Notion, v7.64) | FK `page_id → pages CASCADE`, `user_id → users CASCADE` ; `block_id TEXT`, offsets `anchor_start/anchor_end`, `UNIQUE(page_id, block_id, start, end, emoji, user_id)` | mig. 37 (l.1683–1707) | | `page_follows` | Abonnement aux changements d'une page | PK composite `(page_id → pages CASCADE, user_id → users CASCADE)` | mig. 29 (l.1430–1437) | | `page_verifications` | Badge « page vérifiée ✅ » avec expiration | **PK `page_id → pages CASCADE`** (1 par page) ; `verified_by → users`, `expires_at` | mig. 29 (l.1407–1416) | | `tags` | Étiquettes par utilisateur (`user_id=0` = globales) | FK `user_id → users` ; `UNIQUE(name, user_id)` ; `color` | db.py l.282–290 | | `page_tags` | Association page ↔ tag | PK composite `(page_id → pages CASCADE, tag_id → tags CASCADE)` | db.py l.292–299 | | `custom_emojis` | Emojis personnalisés uploadés par workspace | FK `workspace_id` (défaut 1, **non déclaré**) ; `name`, `url` | mig. 8 (l.371–388) | ### 2.4 Pages & éditeur de blocs | Table | But | Clés / colonnes clés | Emplacement | |---|---|---|---| | `pages` | Page de l'éditeur (wiki) — table pivot du produit | FK `workspace_id → workspaces`, `parent_id → pages` (hiérarchie), `collection_id → collections` (base plein écran, v4.6), `collection_row_id → collection_pages CASCADE` (page-ombre de contenu d'une ligne de base, v6.5, mig. 22 l.997–1020), `locked_by → users` ; colonnes notables : `content` (markdown legacy **ou JSON de blocs** selon `content_format`), `content_format`, `parent_section`, `share_mode`, `published`/`is_published`/`publish_slug`/`is_shared`, `deleted_at` (corbeille), `sort_order`, `cover_url`, `page_icon`, `is_locked`, `full_width`, `font_small`, `search_excluded`, `permission_type` (inherit/restricted/private), `teamspace_id` (ALTER, **sans FK déclarée**), `sync_version` (trigger de concurrence optimiste, mig. 17) | db.py l.154–166 + ALTER l.328–470 ; mig. 7/13/18/22/24/25/29 | | `page_versions` | Snapshots annulables de l'éditeur de blocs (undo/restore, une ligne par save) | FK `page_id → pages CASCADE` ; `title`, `blocks_json` (liste complète des blocs), `note`, `user_id → users` | mig. 7 (l.336–365) | | `page_history` | Historique legacy des **lignes de base** (`collection_pages`) | FK `page_id → collection_pages CASCADE` ; `change_type`, `snapshot_json` | db.py l.249 | | `page_global_templates` | Modèles de page globaux créés par les utilisateurs | `blocks_json` (format éditeur), FK `created_by → users SET NULL` | mig. 13 (l.582–597) | | `synced_blocks` | Bloc synchronisé : source de vérité du contenu, réutilisé sur plusieurs pages | `content` (JSON blocs), `created_by → users` (sans FK), `workspace` (TEXT), `synced` via `page_synced_blocks` | mig. 15 (l.626–662) | | `page_synced_blocks` | Référence page ↔ bloc synchronisé (désynchronisable par page) | FK `page_id → pages CASCADE`, `synced_block_id → synced_blocks CASCADE` ; `block_index`, `UNIQUE(page_id, synced_block_id)` | mig. 15 (l.646–662) | | `page_views` | Compteur de vues quotidien par page (même pattern que `site_views`) | FK `page_id → pages CASCADE` ; PK composite `(page_id, day)`, `views` | mig. 29 (l.1454–1461) | | `pages_fts` | **Table virtuelle FTS5** (`title`, `body`) pour la recherche plein texte de la palette de commandes ; synchronisée par 3 triggers `AFTER INSERT/UPDATE/DELETE` sur `pages` ; backfill au boot ; repli `LIKE` si FTS5 absent | rowid = `pages.id` | mig. 3 (l.156–205) | ### 2.5 Bases de données (collections, style Notion) | Table | But | Clés / colonnes clés | Emplacement | |---|---|---|---| | `collections` | Base de données (collection) | `schema_json` (schéma des propriétés), FK `gitea_owner/gitea_repo` (texte, verrouillage sync), `workspace_id → workspaces`, `created_by → users`, `is_locked`, `is_inline` + `parent_page_id` (base inline), `is_task` + `task_assignee_prop/task_status_prop/task_due_prop → collection_properties SET NULL` (mapping My Tasks, mig. 30 l.1558–1585), `permission_type`, `teamspace_id` (sans FK), `form_config_json`, `sync_version` | db.py l.172 + ALTER db.py l.495–520, mig. 18/24/29/30 | | `collection_pages` | **Ligne** d'une base (row) | FK `collection_id → collections CASCADE`, `parent_id → collection_pages` (hiérarchie interne), `property_values_json` (valeurs par propriété), `gitea_issue_id/gitea_issue_number` (sync Kanban), `cover_url`, `external_event_id` (calendrier, mig. 27), `permission_type`, `sync_version` | db.py l.187 ; mig. 10/18/27 | | `collection_views` | Vues sauvegardées par base (table/board/calendar/gallery/list…) | FK `collection_id → collections CASCADE` ; `view_type`, **`config_json`** (filtres/tri/groupement/columns visibles), `position`, `created_by` (NULL = vue partagée, sinon personnelle — mig. 10) | db.py l.210 ; mig. 10 (l.472–478) | | `collection_properties` | Schéma des propriétés d'une base (remplace `project_properties`) | FK `collection_id → collections CASCADE` ; `prop_type`, `options_json` (select/multi-select), `number_format`, relationnels : `related_collection_id → collections SET NULL`, `relation_property_id/target_property_id → collection_properties SET NULL`, `rollup_function`, `formula_expression`, `validation_json` (mig. 4), `group_name` (mig. 10), `button_automation_id → automations SET NULL` (bouton natif, mig. 26) ; `UNIQUE(collection_id, name)` | db.py l.222–241 ; mig. 4/10/26 | | `collection_data_sources` | Sources de données liées (base liée type Notion « Linked database ») | FK `collection_id → collections CASCADE`, `source_collection_id → collections` ; `is_linked`, `UNIQUE(collection_id, source_collection_id)` | db.py l.480–493 | | `collection_dashboards` | Dashboard = combinaison de vues/widgets sur une page | FK `collection_id → collections CASCADE` ; **`layout_json`** (`{"columns":1,"widgets":[]}`) | db.py l.531–542 | | `database_templates` | Modèles de bases prêts à l'emploi (seed idempotent, v5.3.0 : Meeting notes, etc.) | `name`, `icon`, `description`, `schema_json` | db.py l.272 + mig. 4 (l.390–411) ; seed via `app/services/db_templates.py::SEED_TEMPLATES` | | `page_templates` | Modèles de **ligne** par base, éventuellement récurrents | FK `collection_id → collections CASCADE` ; `property_values_json`, `content_json` (blocs), `is_recurring` + `recurrence_rule` | db.py l.294–303 + ALTER l.512–522 | | `page_dependencies` | Dépendances entre lignes (bloque / bloqué par) | FK `page_id`, `dependency_id → collection_pages CASCADE` ; `dependency_type`, `auto_shift` (overlap/…) ; `UNIQUE(page_id, dependency_id, dependency_type)` | db.py l.551–563 | | `sprints` | Sprints Scrum par base de tâches | FK `collection_id → collections CASCADE` ; `start_date/end_date` (TEXT), `status` (planning/active/done), `goal`, `auto_complete` | db.py l.570–582 | | `sprint_pages` | Lignes affectées à un sprint + vélocité | FK `sprint_id → sprints CASCADE`, `page_id → collection_pages CASCADE` ; `velocity_points`, `status_at_start`, `UNIQUE(sprint_id, page_id)` | db.py l.584–594 | | `reminder_log` | Journal anti-doublon des rappels : 1 ligne par (ligne de base, date d'occurrence) — le rappel ne fire qu'une fois même après restart | FK `page_id → collection_pages CASCADE` ; `UNIQUE(page_id, occurrence_date)` ; alimenté par le scan `reminders_enabled` (`app/config.py` l.75–77) — **il n'existe pas de table `reminders`** ; les dates de rappel proviennent des propriétés `due` des lignes | mig. 11 (l.485–509) | ### 2.6 Automatisation, workers & webhooks | Table | But | Clés / colonnes clés | Emplacement | |---|---|---|---| | `automations` | Règles if-this-then-that (moteur v5.1.0) : `trigger_type` event/cron/button, `event`, `cron_expression`, `collection_id` (TEXT sans FK, scope optionnel), **`condition_json`** (clauses), **`actions_json`** (descripteurs), `enabled`, `run_count`, `last_run_at`, `trigger_mode` (`any`\|`all`, fenêtre de 5 min, mig. 26) | | mig. 5 (l.208–257) + mig. 26 (l.1196–1201) | | `automation_steps` | Étapes ordonnées d'une automatisation v2 (trigger/condition/delay/action, v7.0.0) ; l'engine retombe sur `condition_json/actions_json` si aucune step | FK `automation_id → automations CASCADE` ; `kind`, `position`, `config_json` | mig. 26 (l.1156–1172) | | `automation_runs` | Historique d'exécution (audit + UI Settings) | FK `automation_id → automations CASCADE` ; `status` (fired/skipped/error), `trigger_source`, `collection_id`/`page_id` (TEXT/INT sans FK déclarée), `detail` | mig. 5 (l.242–257) | | `workers` | Snippets Python sandboxés (cron ou manuel), partageables (v7.0.0) | `slug UNIQUE`, FK `workspace_id → workspaces CASCADE`, `code_py`, `schedule_cron`, `shared`, `daily_budget_s` (budget CPU quotidien), `created_by → users` (sans FK déclarée) | mig. 26 (l.1174–1192) | | `worker_runs` | Logs d'exécution des workers | FK `worker_id → workers CASCADE` ; `status`, `logs`, `duration_ms` | mig. 26 (l.1194–1210) | | `webhook_subscriptions` | Abonnement sortant URL + event + secret (créée au boot hors migrations, appelée par `init_db` l.834–837) | `url`, `event`, `secret`, `active` | `app/services/webhook_outbound.py` l.272–286 | | `webhook_deliveries` | Journal de livraison (retry ledger) | FK `webhook_id → webhook_subscriptions CASCADE` ; `status` (pending/retrying/success/error/superseded), `http_code`, `attempt`, `event`, `next_retry_at` (epoch, scheduler v6.4), `payload` (JSON), `duration_ms` | mig. 20 (l.913–928) + mig. 21 (l.940–957) | | `idempotency_keys` | Support d'`Idempotency-Key` sur les POST de l'API v2 (réponse rejouée) | **PK `key TEXT`** ; FK `user_id → users CASCADE` ; `response_json`, `status_code` | mig. 20 (l.930–938) | ### 2.7 IA / Agent FlowDeck | Table | But | Clés / colonnes clés | Emplacement | |---|---|---|---| | `agents` | Définition d'agent (personnel ou workspace) | FK `workspace_id → workspaces`, `created_by → users` ; `agent_type`, `system_instructions`, `model`, `scope_json`, `trigger_json`, `approval_mode` (auto/gated), `is_active`, `UNIQUE(workspace_id, name)` | db.py l.660–680 | | `agent_conversations` | Conversation d'un utilisateur avec un agent | FK `agent_id → agents CASCADE`, `user_id → users` ; `status`, `context_json`, override `provider`/`model` (ALTER db.py l.820–827), `memory_enabled` (mig. 31) | db.py l.681–691 | | `agent_messages` | Messages (rôles user/assistant/tool), coût tokens | FK `conversation_id → agent_conversations CASCADE` ; `tool_calls_json`, `model`, `tokens_used` | db.py l.692–702 | | `agent_actions` | Journal des tool calls exécutés (audit + undo) | FK `conversation_id → … CASCADE` ; `tool_name`, `target_type/target_id`, `payload_json`, `result_json`, `status`, `undo_snapshot_json`, `executed_by → users` | db.py l.703–716 | | `agent_skills` | Skills réutilisables (prompt + outils autorisés) | FK `workspace_id → workspaces`, `created_by → users` ; `prompt_template`, `allowed_tools_json`, `UNIQUE(workspace_id, name)` | db.py l.717–729 | | `agent_triggers` | Déclencheurs d'agent (manual/cron/événement) | FK `agent_id → agents CASCADE` ; `trigger_type`, `config_json`, `is_active`, `last_fired_at` | db.py l.730–740 | | `agent_feedback` | 👍/👎 sur les réponses (rating CHECK up/down) | FK `conversation_id`/`message_id`/`user_id` en `SET NULL` ; `snippet`, `comment` | db.py l.742–756 | | `agent_policies` | Gouvernance par workspace (1 ligne/workspace) | FK `workspace_id → workspaces CASCADE`, `UNIQUE(workspace_id)` ; `allowed_tools_json`, `max_steps`, `require_approval` | mig. 28 (l.1326–1338) | | `agent_approvals` | File d'approbation des actions d'écriture gated | `conversation_id` (INT **sans FK déclarée**), `tool`, `args_json`, `status` (pending/…), `requester_id`/`approver_id → users` | mig. 28 (l.1340–1354) | | `agent_memory` | Résumé mémorisé par conversation (upsert 1:1, v7.54.0) | **PK `conversation_id → agent_conversations CASCADE`** ; `kind` (summary), `content` | mig. 31 (l.1661–1681) | | `agent_connectors` | Catalogue de connecteurs d'agent (custom/Discord/Telegram/MCP, v7.55–7.57) | `name`, `url`, `secret_encrypted` (Fernet), `kind`, `auth` (bearer/…), `tools_json` (cache d'outils MCP), `enabled`, `status`, `created_by` (sans FK) | mig. 32 (l.1640–1659) + mig. 34 (l.1613–1624) | | `connector_tokens` | Tokens OAuth par (kind, user) — Google / M365, chiffrés Fernet | **PK composite `(kind, user_id)`** ; `tokens_enc` | mig. 33 (l.1626–1638) | | `plugins` | Registre des modules activables/désactivables de l'instance (web-tools, web-clipper, automations) — seed idempotent | **PK `slug TEXT`** ; `enabled` | mig. 35 (l.1589–1611) | | `semantic_embeddings` | Recherche sémantique hybride + Ask AI (v6.9) : vecteurs « hashed-TF » 256 dims **sans dépendance externe** ; chunks par ressource polymorphe | **PK composite `(resource_type, resource_id, chunk_id)`** (page \| collection…) ; `chunk_text`, `embedding BLOB`, `model` | mig. 25 (l.1096–1133) | | `semantic_index_state` | État d'indexation incrémentale (dernier index par ressource) | **PK composite `(resource_type, resource_id)`** ; `indexed_at` | mig. 25 (l.1122–1133) | ### 2.8 Sites publics & formulaires (mig. 24, `migrations.py` l.1021–1093) | Table | But | Clés / colonnes clés | |---|---|---| | `sites` | Mini-site public multi-pages (façon Notion Sites) | `slug UNIQUE`, FK `root_page_id → pages CASCADE`, `custom_domain UNIQUE`, `theme`, `password_hash`, `expires_at`, `noindex`, `analytics_id`, `created_by → users` | | `site_pages` | Arbre public ordonné des pages du site | FK `site_id → sites CASCADE`, `page_id → pages CASCADE` ; `position`, `UNIQUE(site_id, page_id)` | | `site_views` | Compteur de vues jour/site (upsert, **pas d'IP brute** — RGPD) | FK `site_id → sites CASCADE` ; PK composite `(site_id, day)`, `views` | | `form_responses` | Journal anonymisé des soumissions de formulaire public | FK `collection_id → collections CASCADE`, `row_id → collection_pages SET NULL` (ligne créée) ; `ip_hash` (hash tournant quotidien, pas d'IP) ; config du form dans `collections.form_config_json` | ### 2.9 Calendrier & réunions | Table | But | Clés / colonnes clés | Emplacement | |---|---|---|---| | `calendar_links` | Lien par utilisateur entre une collection et un calendrier externe (Google REST ou CalDAV générique) | FK `user_id → users CASCADE`, `collection_id → collections CASCADE` ; `provider`, `tokens_enc` (Fernet), `calendar_id`, `date_property`, `sync_token`, `UNIQUE(user_id, provider, calendar_id)` | mig. 27 (l.1231–1245) | | `meeting_transcripts` | Note de réunion (audio + transcription + résumé IA), machine à états du bloc Notion-like (idle/recording/paused/processing/done/failed) | FK `page_id → pages CASCADE` ; `audio_path`, `transcript`, `summary`, `language` ; v7.1.1 : `status`, `mode`, `channels_json`, `instruction_snapshot`, `consent_json`, `segments_json`, `processing_steps_json`, `summary_json`, `generated_title`, `completeness_class`, `quality_score`, `duration_ms/paused_ms`, **`event_occurrence_id`** (index unique partiel : une occurrence calendrier = au plus une note) | mig. 27 (l.1247–1271) + mig. 38 (l.1730–1800) | | `meeting_consent_log` | Journal append-only du consentement d'enregistrement | FK `transcript_id → meeting_transcripts CASCADE` ; `method` (start_attestation/…), `attested_by → users`, `participants_json` | mig. 38 (l.1767–1777) | | `meeting_distributions` | Journal append-only des diffusions de compte-rendu (copy_link/…) | FK `transcript_id → meeting_transcripts CASCADE` ; `channel`, `actor_id → users`, `recipients_json`, `status` | mig. 38 (l.1779–1788) | ### 2.10 Import/export & offline (PWA, clipper) | Table | But | Clés / colonnes clés | Emplacement | |---|---|---|---| | `import_jobs` | Job d'import asynchrone (polling UI) — Notion/CSV/markdown | **PK `id TEXT`** (uuid) ; `source`, `filename`, `status` (queued/running/done/error), `error`, `report_json` | mig. 9 (l.434–451) | | `import_items` | Dédup : 1 ligne par page importée, clé (workspace, source, external_id) → re-import idempotent | FK `workspace_id`/`page_id` **sans REFERENCES déclaré** ; `UNIQUE(workspace_id, source, external_id)` | mig. 9 (l.418–432) | | `offline_sync_queue` | File serveur des mutations reçues des clients hors-ligne (`/api/v2/sync/batch`), rejouables/auditées par device | FK `user_id → users CASCADE` ; `device_id`, `type` (page_create/update/delete/move, collection_*), `payload` (JSON), `client_timestamp`, `server_version`, `status` (pending/syncing/synced/failed), `retries`, `error` ; détection de conflit édit/édit via `sync_version` (mig. 17, triggers sur `pages`, `collection_pages`, `collections`) | mig. 16 (l.668–700) | | `extension_devices` | Devices de l'extension Web Clipper (token hashé, scopes) | FK `user_id → users CASCADE` ; `device_id`, `token_hash`, `scopes`, `revoked`, `UNIQUE(user_id, extension_name, device_id)` | mig. 19 (l.838–857) | | `extension_clips` | Clippings capturés (page cible + workspace cible) | FK `user_id → users CASCADE`, `target_page_id → pages SET NULL`, `target_workspace_id → workspaces SET NULL` ; `clip_type`, `source_url` | mig. 19 (l.861–877) | ### 2.11 API & audit | Table | But | Clés / colonnes clés | Emplacement | |---|---|---|---| | `api_audit_log` | Traînée immuable des mutations de l'API publique v2 | FK `user_id → users SET NULL`, `token_id → api_tokens SET NULL` ; `action`, `resource_type`, `resource_id`, `ip_address`, `detail` | mig. 20 (l.895–911) | | `schema_version` | Registre des migrations appliquées (meta) | **PK `version INTEGER`** ; `name`, `applied_at` | `app/migrations.py` l.41–51 | ### 2.12 Divers `favorites`, `recents`, `notes`, `ai_keywords`, `login_history` déjà couverts ci-dessus. À noter également : les tables temporaires de reconstruction vues dans le code (`comments_new`, `tags_new`) **ne sont pas** des tables permanentes (pattern recreate → copy → drop → rename). --- ## 3. Relations clés (graphe FK simplifié) ```mermaid graph TD USERS[users] WS[workspaces] PAGES[pages] COLS[collections] CP[collection_pages] CPV[collection_properties] CV[collection_views] WS -->|owner_id| USERS WS -->|workspace_members| USERS WS -->|workspace_id| PAGES WS -->|workspace_id| COLS PAGES -->|parent_id self| PAGES PAGES -->|collection_id| COLS PAGES -->|collection_row_id CASCADE| CP CP -->|collection_id CASCADE| COLS CP -->|parent_id self| CP CPV -->|collection_id CASCADE| COLS CPV -->|related_collection_id| COLS CPV -->|relation/target_property_id self| CPV CPV -->|button_automation_id| AUTO[automations] CV -->|collection_id CASCADE| COLS AUTO -->|collection_id texte sans FK| COLS AGENTS[agents] -->|workspace_id| WS CONV[agent_conversations] -->|agent_id CASCADE| AGENTS CONV -->|user_id| USERS MSG[agent_messages + actions] -->|conversation_id CASCADE| CONV GRP[user_groups] -->|workspace_id CASCADE| WS PPERM[page/collection/property_permissions] -->|user_id XOR group_id| USERS PPERM -->|group_id| GRP SITES[sites/site_pages] -->|root/page CASCADE| PAGES TRANS[meeting_transcripts] -->|page_id CASCADE| PAGES CAL[calendar_links] -->|user_id| USERS CAL -->|collection_id CASCADE| COLS CLIPS[extension_clips] -->|target_page_id| PAGES ``` Points de relation à retenir : 1. **`users` est le hub d'identité** : plus de 30 FK entrantes (sessions, tokens, permissions, audit, contenu). `workspaces` est le hub d'isolation multi-tenant (`owner_id`, `workspace_members` définit le rôle). 2. **Hiérarchie `pages ↔ collection_pages`** : deux univers de « pages ». `pages` = éditeur de blocs wiki (FK self `parent_id`, soft-delete `deleted_at`, corbeille) ; `collection_pages` = lignes de base. Le pont v6.5 : chaque ligne peut avoir une page-ombre `pages.collection_row_id` (CASCADE) qui porte son contenu blocs — versions, synced blocks et temps réel opèrent sur elle. 3. **Chaîne de cascades principales** : `collections → collection_pages/collection_views/collection_properties → …` et `pages → page_versions/page_shares/page_synced_blocks/page_permissions/guest_shares/text_reactions`. Les suppressions de ressource nettoyent l'éditorial ; les grants d'audit (`*_audit_log`, `api_audit_log`, `permission_audit_log`) privilégient `SET NULL` sur l'acteur pour survivre à la suppression du compte. 4. **Permissions en 3 niveaux** (mig. 18) : `pages/collections/collection_pages.permission_type` (`inherit`|`restricted`|`private`) pilote si les tables `*_permissions` (user **XOR** group via `CHECK`) font foi ; les groupes (`user_groups`/`group_members`) sont workspace-scopés ; `page_shares` reste le chemin de partage simple, `guest_shares` le chemin sans compte. 5. **Sync sémantique Gitea** : `boards/cards/col_mapping/checklists/property_values` et `collection_pages.gitea_issue_id` + `collections.gitea_owner/repo` relient les entités FlowDeck aux issues Gitea (jointures par ID numérique, **pas de FK** — système externe). 6. **Polymorphisme non-FK** : `notifications.resource_type/resource_id`, `semantic_embeddings.resource_type/resource_id`, `permission_audit_log.resource_type/resource_id`, `import_items.page_id`, `agent_approvals.conversation_id`, `automations.collection_id`, `custom_emojis.workspace_id`, `teamspaces`-id sur `pages`/`collections` (ALTER sans FK) — cohérence maintenue applicativement, pas par le moteur. --- ## 4. Colonnes JSON structurantes Le modèle est fortement « document-oriented » : le JSON TEXT est le format de stockage des structures riches (choix assumé du SQLite sans JSON natif, requêtage via `json_extract` côté applicatif). | Domaine | Table.colonne | Contenu | |---|---|---| | Kanban | `boards.columns_json` / `wip_limits_json` | liste ordonnée des colonnes ; limites WIP par colonne | | Kanban/legacy | `project_properties.options_json` | options de select | | Collections | `collections.schema_json` | schéma complet des propriétés d'une base (déclaratif, dupliqué avec `collection_properties`) | | Collections | `collections.form_config_json` | formulaire public (champs, validations) | | Lignes | `collection_pages.property_values_json` | map `{property_name → valeur}` de la ligne | | Vues | `collection_views.config_json` | **config de vue** : filtres, sorts, groupements, colonnes visibles/cache, sizing — cœur du système de vues multiples | | Propriétés | `collection_properties.options_json` / `validation_json` | options select, règles de validation | | Dashboard | `collection_dashboards.layout_json` | colonnes + widgets | | Modèles | `database_templates.schema_json`, `page_templates.property_values_json` / `content_json` | blueprint de base / ligne | | Éditeur | `pages.content` (avec `content_format='blocks'`) | **tableau JSON des blocs** (heading, todo, callout, synced_block, meeting, etc.) — l'unité de contenu fondamentale | | Historique | `page_versions.blocks_json`, `page_history.snapshot_json`, `page_global_templates.blocks_json`, `synced_blocks.content` | snapshots de liste de blocs | | Workspace | `workspaces.settings_json`, `users.sidebar_config`, `users.notification_prefs`, `users.totp_backup_hashes` | préférences | | Agent | `agents.scope_json`/`trigger_json`, `agent_conversations.context_json`, `agent_messages.tool_calls_json`, `agent_actions.payload_json`/`result_json`/`undo_snapshot_json`, `agent_skills.allowed_tools_json`, `agent_triggers.config_json`, `agent_policies.allowed_tools_json`, `agent_approvals.args_json` | orchestration, audit et undo | | LLM | `user_llm_keys.models_json` | cache des modèles distants | | Automatisation | `automations.condition_json`/`actions_json`, `automation_steps.config_json` | DSL des règles (fallback steps ↔ legacy colonnes) | | Sync offline | `offline_sync_queue.payload`, `idempotency_keys.response_json` | mutation client + réponse rejouée | | Webhooks | `webhook_deliveries.payload` | corps livré | | Réunions | `meeting_transcripts.channels_json`/`consent_json`/`segments_json`/`processing_steps_json`/`summary_json`, `meeting_consent_log.participants_json`, `meeting_distributions.recipients_json` | pipeline transcription | | Imports | `import_jobs.report_json` | rapport par fichier | | SSO | `sso_config.attribute_mapping`, `groups_mapping`, `sso_login_history` (mapping) | attributs d'assertion | --- ## 5. Couche Pydantic (`app/models/`) Les modèles Pydantic ne couvrent **qu'une petite partie** du contrat API — la majorité des routes FastAPI valide en dur dans les handlers : - `app/models/requests.py` (75 l.) : - `FileSaveRequest` — sauvegarde de fichier via Gitea (`path`, `content`, `message`, `sha` optionnel pour update) ; `@model_validator(mode="after")` restreint l'extension via `ALLOWED_EXTENSIONS`/`_ext` (`app/middleware/security`) ; - `IssueCreateRequest` / `IssueUpdateRequest` — création/mutation d'issues Gitea (title ≤ 500, `state` pattern `^(open|closed)$`, labels/milestone/assignee en ids virgule) ; - `CardMoveRequest` — déplacement de carte Kanban (owner/repo/issue_id/column) ; - `ColMappingRequest` — mapping colonne ↔ label ; - `UploadValidationResult` — DTO de validation d'upload (filename/size/extension/valid/error). - `app/models/responses.py` (38 l.) : deux enveloppes standardisées, utilisées en `response_model` OpenAPI des routes publiques : - `ErrorResponse {error, detail?}` ; - `SuccessResponse {status="ok", data?: dict}`. Aucun modèle ORM (pas de SQLAlchemy) : l'accès aux données est du **SQL brut `sqlite3`** via le context-manager `get_conn()` (`app/db.py` l.843–858, `row_factory = sqlite3.Row`), et les conversions dict↔JSON des colonnes `*_json` sont faites manuellement dans les services. --- ## 6. Fiches de synthèse technique | Aspect | Valeur réelle (vérifiée dans le code) | |---|---| | Moteur | SQLite unique, fichier `settings.db_path` (`database_url` = `sqlite:///…`), WAL + `busy_timeout=5000`, `foreign_keys=ON` | | ORM | aucun — SQL brut | | Pilote | `sqlite3` stdlib | | Migrations | maison, versionnées : baseline v1 (`init_db` idempotent) + registry `@register` (`app/migrations.py`), table `schema_version`, transaction par migration | | Version de schéma | 38 | | Triggers | FTS5 (`pages_fts_ai/ad/au`), bumps `sync_version` (`*_sync_version_bu`) sur `pages`/`collection_pages`/`collections` | | Full-text | FTS5 optionnel, repli `LIKE` | | Recherche vectorielle | `semantic_embeddings` (hash TF 256-dim en BLOB, PK composite polymorphe, pas d'extension vec0) | | Chiffrement au repos | Fernet sur `calendar_links.tokens_enc`, `connector_tokens.tokens_enc`, secrets SSO ; hash sha256 pour `api_tokens.token_hash`, `scim_tokens.token_hash`, `extension_devices.token_hash` ; `password_hash` (users) | | Sauvegardes | copie planifiée du fichier SQLite (`backup_*`, `app/config.py` l.66–70) | | Concurrence | verrou applicatif d'écriture indirect via WAL + busy_timeout ; migration async du 510 call sites synchrones encore en cours (note A21, `app/db.py` l.848–852) |