"""FlowDeck — Database layer (SQLite with WAL mode).""" from __future__ import annotations import sqlite3 from contextlib import contextmanager from pathlib import Path def get_db_path() -> Path: from app.config import settings return settings.db_path def init_db(): """Create tables if they don't exist.""" db_path = get_db_path() if str(db_path) != ":memory:": db_path.parent.mkdir(parents=True, exist_ok=True) with get_conn() as conn: conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") conn.executescript(""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, login TEXT NOT NULL UNIQUE, full_name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', avatar_url TEXT NOT NULL DEFAULT '', avatar_color TEXT NOT NULL DEFAULT '#3A3A3A', password_hash TEXT, is_admin INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1, last_login TIMESTAMP, login_attempts INTEGER NOT NULL DEFAULT 0, locked_until TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS login_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), ip_address TEXT DEFAULT '', user_agent TEXT DEFAULT '', logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS user_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, gitea_user_id INTEGER NOT NULL UNIQUE, gitea_token TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS user_oauth_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), provider TEXT NOT NULL, access_token TEXT NOT NULL, refresh_token TEXT, expires_at TIMESTAMP, instance_url TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, provider) ); CREATE TABLE IF NOT EXISTS boards ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_owner TEXT NOT NULL, project_name TEXT NOT NULL, columns_json TEXT NOT NULL DEFAULT '["Backlog","À faire","En cours","Révision","Terminé"]', wip_limits_json TEXT DEFAULT '{}', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(project_owner, project_name) ); CREATE TABLE IF NOT EXISTS cards ( id INTEGER PRIMARY KEY AUTOINCREMENT, board_id INTEGER NOT NULL REFERENCES boards(id), gitea_issue_id INTEGER, column_name TEXT NOT NULL DEFAULT 'Backlog', position INTEGER NOT NULL DEFAULT 0, priority TEXT DEFAULT 'Medium', due_date TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_owner TEXT NOT NULL, project_name TEXT NOT NULL, title TEXT NOT NULL DEFAULT 'Notes', content TEXT NOT NULL DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(project_owner, project_name, title) ); CREATE TABLE IF NOT EXISTS col_mapping ( id INTEGER PRIMARY KEY AUTOINCREMENT, board_id INTEGER NOT NULL REFERENCES boards(id), column_name TEXT NOT NULL, gitea_label TEXT NOT NULL, close_issue BOOLEAN NOT NULL DEFAULT 0, UNIQUE(board_id, column_name) ); CREATE TABLE IF NOT EXISTS checklists ( id INTEGER PRIMARY KEY AUTOINCREMENT, board_id INTEGER NOT NULL, gitea_issue_id INTEGER NOT NULL, title TEXT NOT NULL DEFAULT 'Checklist', position INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(board_id, gitea_issue_id, title) ); CREATE TABLE IF NOT EXISTS checklist_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, checklist_id INTEGER NOT NULL REFERENCES checklists(id) ON DELETE CASCADE, content TEXT NOT NULL DEFAULT '', checked BOOLEAN NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS project_properties ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_owner TEXT NOT NULL, project_name TEXT NOT NULL, name TEXT NOT NULL, prop_type TEXT NOT NULL DEFAULT 'select', options_json TEXT DEFAULT '[]', position INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(project_owner, project_name, name) ); CREATE TABLE IF NOT EXISTS property_values ( id INTEGER PRIMARY KEY AUTOINCREMENT, property_id INTEGER NOT NULL REFERENCES project_properties(id) ON DELETE CASCADE, gitea_issue_id INTEGER NOT NULL, value TEXT NOT NULL DEFAULT '', UNIQUE(property_id, gitea_issue_id) ); CREATE TABLE IF NOT EXISTS ai_keywords ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_owner TEXT NOT NULL, project_name TEXT NOT NULL, keyword TEXT NOT NULL, color TEXT NOT NULL DEFAULT '#787774', usage_count INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(project_owner, project_name, keyword) ); CREATE TABLE IF NOT EXISTS pages ( id INTEGER PRIMARY KEY AUTOINCREMENT, workspace TEXT NOT NULL, workspace_id INTEGER REFERENCES workspaces(id), title TEXT NOT NULL DEFAULT 'New page', content TEXT NOT NULL DEFAULT '', parent_section TEXT DEFAULT 'Private', parent_id INTEGER REFERENCES pages(id), share_mode TEXT DEFAULT 'private', published INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- v1.3.0: Database Concept — Notion-style collections CREATE TABLE IF NOT EXISTS collections ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT DEFAULT '', icon TEXT DEFAULT '📋', schema_json TEXT NOT NULL DEFAULT '[]', gitea_owner TEXT, gitea_repo TEXT, is_locked BOOLEAN NOT NULL DEFAULT 0, is_inline BOOLEAN NOT NULL DEFAULT 0, parent_page_id INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS collection_pages ( id INTEGER PRIMARY KEY AUTOINCREMENT, collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, title TEXT NOT NULL DEFAULT '', icon TEXT DEFAULT 'file', position INTEGER NOT NULL DEFAULT 0, parent_id INTEGER REFERENCES collection_pages(id), gitea_issue_id INTEGER, gitea_issue_number INTEGER, property_values_json TEXT NOT NULL DEFAULT '{}', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_cp_collection ON collection_pages(collection_id, position); CREATE INDEX IF NOT EXISTS idx_cp_parent ON collection_pages(parent_id); CREATE INDEX IF NOT EXISTS idx_cp_gitea ON collection_pages(gitea_issue_id); CREATE TABLE IF NOT EXISTS collection_views ( id INTEGER PRIMARY KEY AUTOINCREMENT, collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, name TEXT NOT NULL DEFAULT 'Default View', view_type TEXT NOT NULL DEFAULT 'table', config_json TEXT NOT NULL DEFAULT '{}', position INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- v1.4.0: Propriétés Avancées — collection_properties (remplace project_properties) CREATE TABLE IF NOT EXISTS collection_properties ( id INTEGER PRIMARY KEY AUTOINCREMENT, collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, name TEXT NOT NULL, prop_type TEXT NOT NULL DEFAULT 'text', options_json TEXT DEFAULT '[]', number_format TEXT DEFAULT 'number', related_collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL, reverse_name TEXT, relation_property_id INTEGER REFERENCES collection_properties(id) ON DELETE SET NULL, target_property_id INTEGER REFERENCES collection_properties(id) ON DELETE SET NULL, rollup_function TEXT, formula_expression TEXT, position INTEGER NOT NULL DEFAULT 0, required BOOLEAN NOT NULL DEFAULT 0, visible_in_views BOOLEAN NOT NULL DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(collection_id, name) ); -- v2.0.0: Multi-user — workspaces, members, comments, history, favorites, templates CREATE TABLE IF NOT EXISTS workspaces ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, owner_id INTEGER NOT NULL REFERENCES users(id), settings_json TEXT NOT NULL DEFAULT '{}', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS workspace_members ( id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id), role TEXT NOT NULL DEFAULT 'editor', joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(workspace_id, user_id) ); CREATE TABLE IF NOT EXISTS comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id), body TEXT NOT NULL DEFAULT '', parent_id INTEGER REFERENCES comments(id), resolved BOOLEAN NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS page_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id), change_type TEXT NOT NULL, snapshot_json TEXT NOT NULL DEFAULT '{}', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- v2.2.0: Migrate favorites — drop old schema referencing collection_pages DROP TABLE IF EXISTS favorites; CREATE TABLE IF NOT EXISTS favorites ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), page_id INTEGER NOT NULL REFERENCES pages(id), position INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, page_id) ); CREATE TABLE IF NOT EXISTS database_templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT DEFAULT '', schema_json TEXT NOT NULL DEFAULT '[]', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS page_templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, name TEXT NOT NULL DEFAULT 'Default', property_values_json TEXT NOT NULL DEFAULT '{}', content_json TEXT DEFAULT '[]', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- v2.2.0: Tags system CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, color TEXT DEFAULT '#787774', user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(name, user_id) ); CREATE TABLE IF NOT EXISTS page_tags ( page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE, tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (page_id, tag_id) ); CREATE INDEX IF NOT EXISTS idx_pt_page ON page_tags(page_id); CREATE INDEX IF NOT EXISTS idx_pt_tag ON page_tags(tag_id); """) # Migration: add parent_id if missing (v1.0.0+) try: conn.execute("ALTER TABLE pages ADD COLUMN parent_id INTEGER REFERENCES pages(id)") except sqlite3.OperationalError: pass # Migration: add avatar_color (v2.3.0+) try: conn.execute("ALTER TABLE users ADD COLUMN avatar_color TEXT NOT NULL DEFAULT '#3A3A3A'") except sqlite3.OperationalError: pass # Migration: add sort_order for drag & drop tree reordering (v1.1.0+) try: conn.execute("ALTER TABLE pages ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0") except sqlite3.OperationalError: pass # Migration: add content_format for Notion-style block editor (v1.2.0+) # 'markdown' = legacy textarea, 'blocks' = JSON array of block objects try: conn.execute("ALTER TABLE pages ADD COLUMN content_format TEXT NOT NULL DEFAULT 'markdown'") except sqlite3.OperationalError: pass # Migration: add deleted_at for trash/soft-delete try: conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT") except sqlite3.OperationalError: pass # v7.45.5 : réparation des pages restaurées marquées 'Trash' — l'ancien # soft-delete (éditeur) réécrivait parent_section='Trash' et la # restauration ne le remettait pas → page invisible en Library/Recents/ # Private. Idempotent (WHERE restrictif), rejoué à chaque boot. # ponytail: un dossier restauré repasse en 'Private' (l'état d'origine # n'est pas stocké) → icône/dossier à remettre à 'Workspace' si besoin. conn.execute( "UPDATE pages SET parent_section='Private' " "WHERE parent_section='Trash' AND deleted_at IS NULL" ) try: conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'") except sqlite3.OperationalError: pass try: conn.execute("ALTER TABLE pages ADD COLUMN published INTEGER DEFAULT 0") except sqlite3.OperationalError: pass try: conn.execute("ALTER TABLE pages ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)") except sqlite3.OperationalError: pass # v2.2: Auth locale for col in ["password_hash", "is_active", "last_login", "login_attempts", "locked_until"]: try: conn.execute(f"ALTER TABLE users ADD COLUMN {col} {'TEXT' if col in ('password_hash','last_login','locked_until') else 'INTEGER NOT NULL DEFAULT ' + ('1' if col=='is_active' else '0')}") except sqlite3.OperationalError: pass # v2.5: Login history try: conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0") except sqlite3.OperationalError: pass try: conn.execute("""CREATE TABLE IF NOT EXISTS login_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), ip_address TEXT DEFAULT '', user_agent TEXT DEFAULT '', logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )""") except sqlite3.OperationalError: pass # v2.6: Tags per-user try: conn.execute("ALTER TABLE tags ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id)") except sqlite3.OperationalError: pass try: # Recreate UNIQUE constraint for per-user tags conn.execute("CREATE TABLE IF NOT EXISTS tags_new (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, color TEXT DEFAULT '#787774', user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(name, user_id))") conn.execute("INSERT OR IGNORE INTO tags_new (id, name, color, user_id, created_at) SELECT id, name, color, COALESCE(user_id,0), created_at FROM tags") conn.execute("DROP TABLE tags") conn.execute("ALTER TABLE tags_new RENAME TO tags") except sqlite3.OperationalError: pass conn.commit() # v2.7: Private pages for Gitea workspaces conn.execute(""" CREATE TABLE IF NOT EXISTS gitea_private_pages ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), gitea_owner TEXT NOT NULL, gitea_repo TEXT NOT NULL, title TEXT NOT NULL DEFAULT 'Untitled', content TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) conn.commit() # ── v4.0: Account & Integrations ── # 1) auth_method on users try: conn.execute("ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'local'") except sqlite3.OperationalError: pass # Detect existing auth: if user has a gitea_token in user_tokens → 'gitea' conn.execute( "UPDATE users SET auth_method='gitea' WHERE id IN (SELECT gitea_user_id FROM user_tokens)" ) conn.execute( "UPDATE users SET auth_method='local' WHERE auth_method IS NULL" ) # 2) Publishing & sharing columns on pages try: conn.execute("ALTER TABLE pages ADD COLUMN is_published INTEGER NOT NULL DEFAULT 0") except sqlite3.OperationalError: pass try: conn.execute("ALTER TABLE pages ADD COLUMN publish_slug TEXT DEFAULT ''") except sqlite3.OperationalError: pass try: conn.execute("ALTER TABLE pages ADD COLUMN is_shared INTEGER NOT NULL DEFAULT 0") except sqlite3.OperationalError: pass # 3) page_shares table conn.execute(""" CREATE TABLE IF NOT EXISTS page_shares ( id INTEGER PRIMARY KEY AUTOINCREMENT, page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE, shared_with_user_id INTEGER REFERENCES users(id), shared_with_group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE, shared_with_email TEXT DEFAULT '', permission TEXT NOT NULL DEFAULT 'view', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_by INTEGER REFERENCES users(id) ) """) # v5.x: migration — partage par groupes (colonne manquante sur DB existantes) try: conn.execute("ALTER TABLE page_shares ADD COLUMN shared_with_group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE") except sqlite3.OperationalError: pass # 4) recents table conn.execute(""" CREATE TABLE IF NOT EXISTS recents ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), page_id INTEGER NOT NULL REFERENCES pages(id), workspace TEXT NOT NULL DEFAULT '', source_type TEXT NOT NULL DEFAULT 'local', accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, page_id) ) """) conn.commit() # v4.1.0: Data Sources & Linked Databases conn.execute(""" CREATE TABLE IF NOT EXISTS collection_data_sources ( id INTEGER PRIMARY KEY AUTOINCREMENT, collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, source_collection_id INTEGER NOT NULL REFERENCES collections(id), source_name TEXT DEFAULT '', is_linked BOOLEAN NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(collection_id, source_collection_id) ) """) # Add workspace_id to collections if missing (v4.1.0 migration) try: conn.execute("ALTER TABLE collections ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)") except sqlite3.OperationalError: pass # Add created_by to collections if missing try: conn.execute("ALTER TABLE collections ADD COLUMN created_by INTEGER REFERENCES users(id)") except sqlite3.OperationalError: pass conn.commit() # v4.6.0: Add collection_id to pages (page ↔ collection link for full-page DBs) try: conn.execute("ALTER TABLE pages ADD COLUMN collection_id INTEGER REFERENCES collections(id)") except sqlite3.OperationalError: pass # v4.2.0: Collection Templates (enhanced) + Dashboards # Add description, is_recurring, recurrence_rule to page_templates try: conn.execute("ALTER TABLE page_templates ADD COLUMN description TEXT DEFAULT ''") except sqlite3.OperationalError: pass try: conn.execute("ALTER TABLE page_templates ADD COLUMN is_recurring BOOLEAN NOT NULL DEFAULT 0") except sqlite3.OperationalError: pass try: conn.execute("ALTER TABLE page_templates ADD COLUMN recurrence_rule TEXT DEFAULT ''") except sqlite3.OperationalError: pass # Dashboard: combinaison de vues/widgets sur une page conn.execute(""" CREATE TABLE IF NOT EXISTS collection_dashboards ( id INTEGER PRIMARY KEY AUTOINCREMENT, collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, name TEXT NOT NULL DEFAULT 'Dashboard', layout_json TEXT NOT NULL DEFAULT '{"columns":1,"widgets":[]}', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) conn.commit() # v4.4.0: Tasks & Dependencies # Add is_task flag to collections try: conn.execute("ALTER TABLE collections ADD COLUMN is_task BOOLEAN NOT NULL DEFAULT 0") except sqlite3.OperationalError: pass # Dependencies table: bloque/bloqué par with auto-shift config conn.execute(""" CREATE TABLE IF NOT EXISTS page_dependencies ( id INTEGER PRIMARY KEY AUTOINCREMENT, page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE, dependency_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE, dependency_type TEXT NOT NULL DEFAULT 'blocks', auto_shift TEXT DEFAULT 'overlap', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(page_id, dependency_id, dependency_type) ) """) conn.commit() # v4.5.0: Sprints conn.execute(""" CREATE TABLE IF NOT EXISTS sprints ( id INTEGER PRIMARY KEY AUTOINCREMENT, collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, name TEXT NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL, goal TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'planning', auto_complete BOOLEAN NOT NULL DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS sprint_pages ( id INTEGER PRIMARY KEY AUTOINCREMENT, sprint_id INTEGER NOT NULL REFERENCES sprints(id) ON DELETE CASCADE, page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE, status_at_start TEXT DEFAULT '', velocity_points INTEGER DEFAULT 1, UNIQUE(sprint_id, page_id) ) """) conn.commit() # v4.6.0: Sidebar customization config per user try: conn.execute("ALTER TABLE users ADD COLUMN sidebar_config TEXT DEFAULT '{}'") except sqlite3.OperationalError: pass conn.commit() # ═══════════ v4.9.0: Collaboration — notifications, inline comments, prefs ═══════════ # Notifications table (mentions, comments, page changes) conn.execute(""" CREATE TABLE IF NOT EXISTS notifications ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, actor_id INTEGER REFERENCES users(id), ntype TEXT NOT NULL DEFAULT 'mention', -- 'mention' | 'comment' | 'page' title TEXT NOT NULL DEFAULT '', message TEXT NOT NULL DEFAULT '', resource_type TEXT NOT NULL DEFAULT 'page', resource_id INTEGER NOT NULL DEFAULT 0, url TEXT NOT NULL DEFAULT '', is_read INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) conn.execute( "CREATE INDEX IF NOT EXISTS idx_notif_user_read ON notifications(user_id, is_read)" ) # Notification email preferences (JSON: {"comments": true, "mentions": true}) try: conn.execute("ALTER TABLE users ADD COLUMN notification_prefs TEXT DEFAULT '{}'") except sqlite3.OperationalError: pass # Inline comments on pages: the v2.0.0 `comments` table had a NOT NULL FK to # collection_pages, which prevents using page-editor (pages) ids. Rebuild it so # it can hold page comments with optional inline anchors, while preserving data. # target_type='collection_page' (legacy) or 'page' (editor); target_id = resource id. # anchor_block_id = block id; anchor_start/anchor_end = text selection offsets. _cols = [r[1] for r in conn.execute("PRAGMA table_info(comments)").fetchall()] if "target_type" not in _cols: try: conn.execute(""" CREATE TABLE comments_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, page_id INTEGER, user_id INTEGER NOT NULL REFERENCES users(id), body TEXT NOT NULL DEFAULT '', parent_id INTEGER, resolved BOOLEAN NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, target_type TEXT NOT NULL DEFAULT 'page', target_id INTEGER NOT NULL DEFAULT 0, anchor_block_id TEXT, anchor_start INTEGER, anchor_end INTEGER ) """) conn.execute( """INSERT INTO comments_new (id, page_id, user_id, body, parent_id, resolved, created_at, updated_at, target_type, target_id) SELECT id, page_id, user_id, body, parent_id, resolved, created_at, updated_at, 'collection_page', COALESCE(page_id, 0) FROM comments""" ) conn.execute("DROP TABLE comments") conn.execute("ALTER TABLE comments_new RENAME TO comments") except sqlite3.OperationalError: pass conn.commit() # ═══════════ v4.10.0: FlowDeck Agent — agents, conversations, audit ═══════════ conn.execute(""" CREATE TABLE IF NOT EXISTS agents ( id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id INTEGER REFERENCES workspaces(id), name TEXT NOT NULL DEFAULT 'FlowDeck Agent', icon TEXT DEFAULT '🤖', agent_type TEXT NOT NULL DEFAULT 'personal', description TEXT DEFAULT '', system_instructions TEXT DEFAULT '', model TEXT DEFAULT 'gpt-4o', scope_json TEXT NOT NULL DEFAULT '{}', trigger_json TEXT NOT NULL DEFAULT '{}', approval_mode TEXT NOT NULL DEFAULT 'auto', is_active BOOLEAN NOT NULL DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_by INTEGER REFERENCES users(id), UNIQUE(workspace_id, name) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS agent_conversations ( id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id), title TEXT DEFAULT 'New conversation', status TEXT NOT NULL DEFAULT 'idle', context_json TEXT NOT NULL DEFAULT '{}', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS agent_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE, role TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', tool_calls_json TEXT DEFAULT '[]', model TEXT, tokens_used INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS agent_actions ( id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE, tool_name TEXT NOT NULL, target_type TEXT, target_id TEXT, payload_json TEXT NOT NULL DEFAULT '{}', result_json TEXT NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'success', undo_snapshot_json TEXT DEFAULT '{}', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, executed_by INTEGER REFERENCES users(id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS agent_skills ( id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id INTEGER REFERENCES workspaces(id), name TEXT NOT NULL, description TEXT DEFAULT '', prompt_template TEXT NOT NULL, allowed_tools_json TEXT NOT NULL DEFAULT '[]', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_by INTEGER REFERENCES users(id), UNIQUE(workspace_id, name) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS agent_triggers ( id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE, trigger_type TEXT NOT NULL DEFAULT 'manual', config_json TEXT NOT NULL DEFAULT '{}', is_active BOOLEAN NOT NULL DEFAULT 1, last_fired_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) # ─── v4.15.0: feedback des réponses de l'agent (👍 / 👎) ─────────────── conn.execute(""" CREATE TABLE IF NOT EXISTS agent_feedback ( id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id INTEGER REFERENCES agent_conversations(id) ON DELETE SET NULL, message_id INTEGER REFERENCES agent_messages(id) ON DELETE SET NULL, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, rating TEXT NOT NULL CHECK (rating IN ('up', 'down')), snippet TEXT DEFAULT '', comment TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_feedback_conv ON agent_feedback(conversation_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_feedback_user ON agent_feedback(user_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_user ON agent_conversations(user_id, updated_at)") conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_msg_conv ON agent_messages(conversation_id, created_at)") conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_action_conv ON agent_actions(conversation_id)") # ─── v4.10.1: LLM runtime config (single row id=1) ───────────────────── # Created lazily (no seed) so .env stays the default until an admin saves # the LLM settings from the UI. Precedence: DB row > settings.llm_*. conn.execute(""" CREATE TABLE IF NOT EXISTS llm_config ( id INTEGER PRIMARY KEY CHECK (id = 1), provider TEXT NOT NULL DEFAULT 'offline', model TEXT DEFAULT '', api_key TEXT DEFAULT '', api_base TEXT DEFAULT '', verified INTEGER NOT NULL DEFAULT 0, verified_model TEXT DEFAULT '', verified_at TIMESTAMP, last_error TEXT DEFAULT '', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) # ─── v4.10.2: per-user provider API keys ────────────────────────────── # Each user can save several providers with their own key/base + the # live model list fetched from the provider (models_json cache). conn.execute(""" CREATE TABLE IF NOT EXISTS user_llm_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, provider TEXT NOT NULL, api_key TEXT NOT NULL DEFAULT '', api_base TEXT NOT NULL DEFAULT '', default_model TEXT DEFAULT '', models_json TEXT NOT NULL DEFAULT '[]', verified INTEGER NOT NULL DEFAULT 0, verified_model TEXT DEFAULT '', verified_at TIMESTAMP, last_error TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, provider) ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_user_llm_keys_user ON user_llm_keys(user_id)") # v4.12: provider activation/verification — a provider is only offered in # the Agent UI once it is configured AND its connection test succeeded. for col, ddl in ( ("verified", "INTEGER NOT NULL DEFAULT 0"), ("verified_model", "TEXT DEFAULT ''"), ("verified_at", "TIMESTAMP"), ("last_error", "TEXT DEFAULT ''"), ): try: conn.execute(f"ALTER TABLE user_llm_keys ADD COLUMN {col} {ddl}") except sqlite3.OperationalError: pass # column already exists for col, ddl in ( ("verified", "INTEGER NOT NULL DEFAULT 0"), ("verified_model", "TEXT DEFAULT ''"), ("verified_at", "TIMESTAMP"), ("last_error", "TEXT DEFAULT ''"), ): try: conn.execute(f"ALTER TABLE llm_config ADD COLUMN {col} {ddl}") except sqlite3.OperationalError: pass # column already exists # Migration: per-conversation provider/model override columns for col in ("provider", "model"): try: conn.execute(f"ALTER TABLE agent_conversations ADD COLUMN {col} TEXT DEFAULT ''") except sqlite3.OperationalError: pass # column already exists conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_llm ON agent_conversations(provider, model)") conn.commit() # ── v5.2.0: apply any pending VERSIONED migrations (schema_version) ── from app.migrations import apply_migrations apply_migrations(conn) # Webhook subscriptions (v2.1.0) — created here (idempotent) so the full # schema exists without depending on the FastAPI lifespan startup. from app.services.webhook_outbound import init_webhook_tables init_webhook_tables() @contextmanager def get_conn(): """Yield a SQLite connection (WAL mode, foreign keys on).""" db_path = get_db_path() conn = sqlite3.connect(str(db_path)) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") # A21 (partiel) : un seul writer par DB — sans timeout la requête concurrente # échoue immédiatement avec « database is locked » (tests xdist, schedulers). # ponytail: le reste d'A21 (wrapper async + 510 call sites synchrones sur # l'event loop) reste à migrer module par module. conn.execute("PRAGMA busy_timeout=5000") try: yield conn finally: conn.close()