Files
flowdeck/docs/DATA_MODEL.md
T
bruno 0218d8f5e5
FlowDeck CI / lint (push) Successful in 1m37s
FlowDeck CI / test (push) Failing after 24m19s
FlowDeck CI / docker (push) Skipped
fix: /local-workspace — chemin du header + clic sur un dossier du sidebar (v7.69.9)
Corrige deux régressions de /local-workspace :

- le chemin du header restait bloqué sur « Home / <workspace> » quel que
  soit le dossier affiché : la route rend désormais breadcrumb_items
  (Home / <workspace> / <dossier> / <sous-dossier>, niveaux cliquables,
  collapse « … » au-delà de 4) et la navigation sans rechargement recalcule
  le chemin via l'event flowdeck:breadcrumb-changed ;
- le clic sur un dossier du sidebar affichait TOUS les composants à la
  fois : Alpine.data('wsInitData') retournait le même objet singleton, le
  2e montage (navigation partielle) levait « Cannot redefine property:
  \ » et initTree abandonnait, laissant tout le contenu au state
  brut. La factory retourne désormais une enveloppe fraîche par montage
  qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque
  exécution (le 2e montage gardait le folder_id du 1er chargement).

Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
2026-10-09 16:58:04 -04:00

334 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<token>` (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) |