diff --git a/.env.example b/.env.example index 3d2c618d..905e81d3 100644 --- a/.env.example +++ b/.env.example @@ -27,11 +27,13 @@ FRONTEND_URL= PUBLIC_POSTHOG_PROJECT_TOKEN= PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -# ── Connection encryption (advanced) ── -# Encryption key for storing database connection strings at rest. -# If unset, a key is auto-generated and stored in ~/.bolodb/.secret. -# RECENT_CONNECTIONS_MASTER_KEY= # Master key for key rotation (encrypts the key file) -# RECENT_CONNECTIONS_KEY= # Direct encryption key (alternative to master key) +# ── Connection encryption (required for storing DB URLs at rest) ── +# Encrypts saved connection URLs. REQUIRED — the app will not start without it, +# and saved connections cannot be stored or reopened until it is set. It is read +# only from the environment (never written to disk), so keep it stable across +# deploys: change it and previously saved connections must be re-added. +# Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))" +RECENT_CONNECTIONS_KEY= # ── Server (advanced) ── # Set to "true" when behind HTTPS termination (nginx with SSL) @@ -42,3 +44,9 @@ SKIP_DB_LOCK=false # CORS_ORIGINS= # Set automatically in Docker; controls SQLite path selection # RUNNING_IN_DOCKER=true + +# ── Maintenance & Storage ── +ACTIVITY_LOG_RETENTION_DAYS=30 +ACTIVITY_CLEANUP_ENABLED=true +ACTIVITY_CLEANUP_INTERVAL_HOURS=24 +BOLODB_DATA_DIR= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76d0a355..38f078af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,10 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install ruff + # Pin to match .pre-commit-config.yaml so CI and local hooks lint + # identically; an unpinned install pulls whatever ruff released last, + # whose changing defaults have broken CI before. + pip install ruff==0.15.22 pip install -r backend/requirements.txt - name: Lint with Ruff diff --git a/.gitignore b/.gitignore index d74c3179..d4eed0cb 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ credentials.json *.key .credentials docker-compose.override.yml +venv/ + +.opencode/ +.agents/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8a9f633..bce8b4f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ BoloDB is built using a modern containerized stack designed for ease of local de - **Backend:** FastAPI (Python 3.10+) utilizing SQLAlchemy for database connectivity, and `sqlglot` for secure AST-based read-only validation. - **Frontend:** A SvelteKit application utilizing TypeScript and Tailwind CSS for a polished, responsive user interface. - **Database:** PostgreSQL container storing user accounts, query history, conversations, and recent connections. -- **Knowledge Base:** Per-user SQLite database storing verified queries, glossary, and trust level. +- **Knowledge Base:** PostgreSQL persistence storing verified queries, glossary, and trust level. - **Reverse Proxy:** Nginx configured to route traffic seamlessly between the frontend and backend services while handling proxy configurations. - **LLM Connectors:** Support for OpenRouter API. diff --git a/DOCKERFILE.render b/DOCKERFILE.render index 77f8d4e5..51cfd5bc 100644 --- a/DOCKERFILE.render +++ b/DOCKERFILE.render @@ -45,9 +45,14 @@ COPY --from=frontend-builder /app/build /usr/share/nginx/html COPY nginx.conf.render /etc/nginx/conf.d/default.conf RUN addgroup --system bolodb && adduser --system --home /home/bolodb --ingroup bolodb bolodb \ - && mkdir -p /home/bolodb/.bolodb \ - && chown -R bolodb:bolodb /home/bolodb/.bolodb /app + && mkdir -p /app/data \ + && chmod +x /app/backend/docker-entrypoint.sh \ + && chown -R bolodb:bolodb /app EXPOSE 80 +# Same as the compose image: migrate to head before serving, so pointing +# DATABASE_URL at a new database needs no manual step. +ENTRYPOINT ["/app/backend/docker-entrypoint.sh"] + CMD sh -c "nginx -g 'daemon off;' & exec python -m uvicorn backend.app.server:create_app --factory --host 0.0.0.0 --port 4321 --log-level info" diff --git a/LICENSE b/LICENSE index 2c3cc8f1..739588a7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 HAAHIT +Copyright (c) 2026 BoloDB Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index bcd8b8a7..879b8cd2 100644 --- a/README.md +++ b/README.md @@ -4,102 +4,98 @@ **Ask your data. Trust the answer.** -A text-to-SQL product for non-technical users. Connect any SQL database, ask -questions in plain English, get answers with a plain-English explanation and a -confidence level. Every answer you confirm teaches it your database — accuracy -improves with use. +A multi-tenant text-to-SQL web application for non-technical users and teams. Connect your database, ask questions in plain English, and get instant answers with plain-English restatements, ECharts visualizations, and confidence levels. Save queries to interactive dashboards and manage multi-user workspaces with role-based access control (RBAC). -**📚 Full documentation lives in [`docs/`](docs/README.md)** — written for -non-technical readers, with code pointers for developers at every step. +**📚 Full documentation lives in [`docs/`](docs/README.md)** — written for non-technical readers, with code pointers for developers at every step. + +--- ## Quick Start (Docker) -The easiest and recommended way to run BoloDB is using Docker. This ensures all services (FastAPI Backend, SvelteKit Frontend, Nginx, and MongoDB) run seamlessly. +The easiest and recommended way to run BoloDB is using Docker Compose (FastAPI backend + SvelteKit frontend + Nginx + PostgreSQL). + +1. Install [Docker Desktop](https://www.docker.com/products/docker-desktop/) or Docker Engine. +2. Clone the repository and navigate into the root directory. +3. Copy `.env.example` to `.env` and fill in required secrets (`OPENROUTER_API_KEY`, `JWT_SECRET`, `DATABASE_URL`, `RECENT_CONNECTIONS_KEY`, `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_JWT_SECRET`). +4. Start the application stack: -1. Install [Docker Desktop](https://www.docker.com/products/docker-desktop/) (Windows/Mac) or Docker Engine (Linux). -2. Open a terminal in the project directory. -3. Start the application: + **Production / Deployment**: ```bash - docker compose up -d + docker compose up --build -d ``` -4. Open [http://localhost:5173](http://localhost:5173) in your browser. -5. Set the `OPENROUTER_API_KEY` environment variable in your docker-compose or `.env` file, connect a database (or click "Try with sample data"), and start asking! + Open [http://localhost:8080](http://localhost:8080). -## The AI engine + **Local Development (Vite HMR)**: + ```bash + docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build + ``` + Open [http://localhost:8080](http://localhost:8080) (Nginx proxy) or [http://localhost:5174](http://localhost:5174) (Vite frontend). -BoloDB uses **OpenRouter** (specifically the `deepseek/deepseek-v4-flash` model) for all AI operations. You only need one thing: -an OpenRouter API key. +--- -1. Go to https://openrouter.ai/keys. -2. Click **Create Key** and copy it. -3. Set it as the `OPENROUTER_API_KEY` environment variable for your deployment. +## Key Features -| Model | Best for | -|---|---| -| `deepseek/deepseek-v4-flash` (default) | Best cost/accuracy balance for most uses | +- **Multi-Tenant Workspaces & RBAC**: Isolate connections, dashboards, and knowledge bases per workspace. Assign Owner, Admin, or Member roles with fine-grained capability checks. +- **OpenRouter AI Engine**: Powered by `deepseek/deepseek-v4-flash` via OpenRouter. +- **Interactive Dashboards & Charts**: Automated ECharts visual inference (Bar, Line, Area, Pie, Number, Table) with query panel customization. +- **Semantic Layer**: Define custom business metrics, explicit join paths, synonyms, and value mappings to guide AI SQL generation. +- **Safety & Defense in Depth**: Read-only AST validation, SSRF protection, host allowlisting, and 5s statement timeouts. +- **PostgreSQL Persistence**: All state (users, workspaces, connections encrypted at rest, query history, dashboards, verified Q&A) is persisted asynchronously in PostgreSQL. -**Privacy:** what's sent to the AI to generate SQL is your question plus the -context BoloDB builds around it: the database *structure* (table/column names, -keys), a few sample values and sample rows per table, your confirmed -business-term glossary, previously verified question→SQL examples, and the -last couple of conversation turns. **The prompt never includes** bulk table -contents, query results, or credentials — the OpenRouter API key travels only in -the request's authentication header. See -[docs/03-the-ai-layer-openrouter.md](docs/03-the-ai-layer-openrouter.md) for exactly -what's in every prompt. +--- -## Database connection strings +## Supported Database Connections -| Database | Format | +| Database | Connection URL Format | |---|---| -| SQLite | `sqlite:///C:/path/to/file.db` | | PostgreSQL | `postgresql://user:pass@host:5432/dbname` | | MySQL | `mysql+pymysql://user:pass@host:3306/dbname` | +| SQLite | `sqlite:///C:/path/to/file.db` | | SQL Server | `mssql+pyodbc://user:pass@server/db?driver=ODBC+Driver+17+for+SQL+Server` | +| DuckDB | `duckdb:///path/to/file.duckdb` | + +--- + +## Architecture & Code Map + +Full file and directory index is available in [`docs/07-file-map.md`](docs/07-file-map.md). + +```text + Browser (SvelteKit 5 Frontend, frontend/src) + │ HTTP / SSE streaming (JWT cookie auth, X-Workspace-Id and X-Db-Id headers) + ▼ + FastAPI Backend (backend/app) + ├── controllers/query.py ─── The query pipeline (knowledge → schema link → LLM → repair) + ├── llm.py ───────────────── OpenRouter provider (deepseek/deepseek-v4-flash) + ├── schema_link.py ───────── Schema linking, budget management & table scoring + ├── sqlvalidate.py ───────── AST static SQL validation + ├── repair.py ────────────── Self-repair loop for auto-correcting broken SQL + ├── semantic.py ──────────── Semantic layer catalog & inference + ├── database.py ──────────── DB introspection & read-only execution guard + └── pgdatabase/ ──────────── Async PostgreSQL persistence (KnowledgeService, Users, Workspaces, Dashboards) +``` -Tip: connect with a **read-only database account** for safety (BoloDB also -enforces read-only itself — see -[docs/05-safety-validation-and-self-repair.md](docs/05-safety-validation-and-self-repair.md)). - -## How it works - -1. **Connect** a database and ensure your OpenRouter API key is set in the environment. -2. **Onboard** (first time) — BoloDB profiles the tables, confirms business-term meanings with you, and runs a few starter questions for you to verify -3. **Ask** questions in plain English. Every answer includes: - - A plain-English restatement ("I summed completed orders for this month") - - A confidence level (High/Medium/Low) based on real signals - - The results table and SQL on a toggle - - Automatic self-repair: generated SQL is validated against your schema and fixed before you ever see an error -4. **Verify** — click "Yes, correct" to save the answer. Similar future questions reuse it and show higher confidence. The trust level climbs from Supervised to Assisted to Trusted - -The full pipeline, step by step with code pointers: -[docs/02-how-a-question-becomes-an-answer.md](docs/02-how-a-question-becomes-an-answer.md). +--- -## Useful Docker Commands +## Running Unit Tests ```bash -docker compose up -d # Start the application in the background -docker compose logs -f # View live logs from all services -docker compose down # Stop all services -docker compose build --no-cache # Rebuild all images from scratch +pip install -r backend/requirements.txt +pytest tests -v ``` -## Running tests +The test suite runs entirely offline using mock OpenRouter providers. -```bash -pip install -r backend/requirements.txt -pytest tests -``` +--- -The test suite needs no network and no API key — all AI calls are faked. +## Privacy & Security -## Privacy +- Connection credentials are **encrypted at rest** using Fernet symmetric encryption key (`RECENT_CONNECTIONS_KEY`). +- AI prompts include only database structure, compact column schemas, verified Q&A examples, and business glossary definitions. Bulk data rows, query results, and credentials are **never sent** to the AI model. +- All SQL execution runs in strict **read-only** mode with statement timeout protection. -- All learned knowledge and user settings are stored locally (`~/.bolodb/`) and in the local MongoDB container volume. The API key is read from the environment and never stored on disk. -- To generate SQL, the AI is sent your question plus context: the schema, a few sample values/rows per table, your confirmed glossary terms, verified question→SQL examples, and recent conversation turns. The prompt never includes bulk table data, query results, or credentials (the API key is used only as the request's authentication header). -- Queries run strictly read-only. -- No telemetry, no cloud sync. +--- ## License -MIT — see [LICENSE](LICENSE). +MIT — see [LICENSE](LICENSE). diff --git a/backend/DOCKERFILE b/backend/DOCKERFILE index 7407d702..69c78baf 100644 --- a/backend/DOCKERFILE +++ b/backend/DOCKERFILE @@ -20,9 +20,12 @@ RUN pip install --no-cache-dir -r /app/backend/requirements.txt COPY backend/ /app/backend/ # ── 3. Create non-root user ── +# /app/data holds the regenerable sample database; create it owned by the +# runtime user so it is writable whether or not a volume is mounted over it. RUN addgroup --system bolodb && adduser --system --home /home/bolodb --ingroup bolodb bolodb \ - && mkdir -p /home/bolodb/.bolodb \ - && chown -R bolodb:bolodb /home/bolodb/.bolodb /app + && mkdir -p /app/data \ + && chmod +x /app/backend/docker-entrypoint.sh \ + && chown -R bolodb:bolodb /app USER bolodb @@ -31,5 +34,9 @@ EXPOSE 4321 HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD python -c "import httpx; httpx.get('http://localhost:4321/api/health', timeout=3).raise_for_status()" || exit 1 +# Migrations run first, so a deploy pointed at a new or stale database brings +# the schema to head before the app serves its first request. +ENTRYPOINT ["/app/backend/docker-entrypoint.sh"] + CMD ["python", "-m", "uvicorn", "backend.app.server:create_app", \ "--factory", "--host", "0.0.0.0", "--port", "4321", "--log-level", "warning"] diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 2c04e642..a8b8b30d 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -39,9 +39,23 @@ def run_migrations_offline() -> None: context.run_migrations() +# Any 64-bit constant works; it only has to be the same in every process that +# migrates this database. Derived from the project name so it cannot collide +# with an advisory lock another application takes on a shared cluster. +_MIGRATION_LOCK_ID = 8410231166942030001 + + def do_run_migrations(connection): context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): + # Containers start migrations concurrently on a scaled deployment, and + # two Alembic runs against one database race on alembic_version. The + # transaction-scoped advisory lock makes the second wait for the first + # and then find nothing left to apply; it is released on commit or + # rollback, so a crashed migration cannot wedge later deploys. + connection.exec_driver_sql( + f"SELECT pg_advisory_xact_lock({_MIGRATION_LOCK_ID})" + ) context.run_migrations() diff --git a/backend/alembic/versions/0001_initial_schema.py b/backend/alembic/versions/0001_initial_schema.py deleted file mode 100644 index 7a7860a4..00000000 --- a/backend/alembic/versions/0001_initial_schema.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Create initial schema (users, conversations, query_history, recent_connections) - -Revision ID: 0001 -Revises: -Create Date: 2026-07-13 -""" - -from typing import Sequence, Union -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects.postgresql import UUID, JSONB - -revision: str = "0001" -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto") - # Primary keys carry no server_default: the application is the sole writer - # and generates UUIDv7 ids in Python (pgdatabase._uuid7). A gen_random_uuid() - # default would emit UUIDv4 on any direct insert/backfill, producing a mix of - # UUID versions and losing the time-ordering UUIDv7 provides. - op.create_table( - "users", - sa.Column( - "id", - UUID(as_uuid=True), - primary_key=True, - ), - sa.Column("email", sa.String(), nullable=False, unique=True), - sa.Column("hashed_pass", sa.String(), nullable=False, server_default=""), - sa.Column("role", sa.String(), nullable=False, server_default="user"), - sa.Column("google_id", sa.String(), nullable=True, unique=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - ) - - op.create_table( - "conversations", - sa.Column( - "id", - UUID(as_uuid=True), - primary_key=True, - ), - sa.Column( - "user_id", - UUID(as_uuid=True), - sa.ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("title", sa.String(), nullable=False, server_default=""), - sa.Column("database_id", sa.String(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - ) - - op.create_table( - "query_history", - sa.Column( - "id", - UUID(as_uuid=True), - primary_key=True, - ), - sa.Column( - "user_id", - UUID(as_uuid=True), - sa.ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("question", sa.Text(), nullable=False), - sa.Column("sql", sa.Text(), nullable=False, server_default=""), - sa.Column("result", JSONB(), nullable=True), - sa.Column("confidence", sa.String(), nullable=False, server_default="low"), - sa.Column("restatement", sa.Text(), nullable=False, server_default=""), - sa.Column( - "conversation_id", - UUID(as_uuid=True), - sa.ForeignKey("conversations.id", ondelete="SET NULL"), - nullable=True, - ), - sa.Column( - "timestamp", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - ) - - op.create_table( - "recent_connections", - sa.Column( - "id", - UUID(as_uuid=True), - primary_key=True, - ), - sa.Column( - "user_id", - UUID(as_uuid=True), - sa.ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("db_url", sa.Text(), nullable=False), - sa.Column("display_url", sa.String(), nullable=False), - sa.Column("dialect", sa.String(), nullable=False), - sa.Column("db_id", sa.String(), nullable=False), - sa.Column("table_count", sa.Integer(), nullable=False, server_default="0"), - sa.Column( - "connected_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.UniqueConstraint("user_id", "db_id"), - ) - - op.create_index("idx_query_history_user_id", "query_history", ["user_id"]) - op.create_index( - "idx_query_history_conversation_id", "query_history", ["conversation_id"] - ) - op.create_index( - "idx_query_history_timestamp", - "query_history", - ["user_id", sa.text("timestamp DESC")], - ) - op.create_index( - "idx_conversations_user_id", - "conversations", - ["user_id", sa.text("updated_at DESC")], - ) - op.create_index("idx_recent_connections_user_id", "recent_connections", ["user_id"]) - - -def downgrade() -> None: - op.drop_table("recent_connections") - op.drop_table("query_history") - op.drop_table("conversations") - op.drop_table("users") diff --git a/backend/alembic/versions/0002_add_supabase_id.py b/backend/alembic/versions/0002_add_supabase_id.py deleted file mode 100644 index 5c9fb884..00000000 --- a/backend/alembic/versions/0002_add_supabase_id.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Add supabase_id column to users table - -Revision ID: 0002 -Revises: 0001 -Create Date: 2026-07-15 -""" - -from typing import Sequence, Union -from alembic import op -import sqlalchemy as sa - -revision: str = "0002" -down_revision: Union[str, None] = "0001" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.add_column( - "users", - sa.Column("supabase_id", sa.String(), nullable=True, unique=True), - ) - op.create_index("ix_users_supabase_id", "users", ["supabase_id"], unique=True) - - -def downgrade() -> None: - op.drop_index("ix_users_supabase_id", table_name="users") - op.drop_column("users", "supabase_id") diff --git a/backend/alembic/versions/0003_add_email_verified_and_otp_codes.py b/backend/alembic/versions/0003_add_email_verified_and_otp_codes.py deleted file mode 100644 index 0c4fa9af..00000000 --- a/backend/alembic/versions/0003_add_email_verified_and_otp_codes.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Add email_verified column and otp_codes table - -Revision ID: 0003 -Revises: 0002 -Create Date: 2026-07-16 -""" - -from typing import Sequence, Union -from alembic import op -import sqlalchemy as sa - -revision: str = "0003" -down_revision: Union[str, None] = "0002" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.add_column( - "users", - sa.Column( - "email_verified", sa.Boolean(), nullable=False, server_default="false" - ), - ) - op.create_table( - "otp_codes", - sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), primary_key=True), - sa.Column( - "user_id", - sa.dialects.postgresql.UUID(as_uuid=True), - sa.ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("code_hash", sa.String(), nullable=False), - sa.Column("purpose", sa.String(), nullable=False), - sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), - sa.Column("used", sa.Boolean(), nullable=False, server_default="false"), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.UniqueConstraint("user_id", "purpose", name="uq_otp_user_purpose"), - ) - op.create_index("ix_otp_codes_user_id", "otp_codes", ["user_id"]) - - -def downgrade() -> None: - op.drop_index("ix_otp_codes_user_id", table_name="otp_codes") - op.drop_table("otp_codes") - op.drop_column("users", "email_verified") diff --git a/backend/alembic/versions/0004_add_password_reset_tokens.py b/backend/alembic/versions/0004_add_password_reset_tokens.py deleted file mode 100644 index 25204538..00000000 --- a/backend/alembic/versions/0004_add_password_reset_tokens.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Add password_reset_tokens table - -Revision ID: 0004 -Revises: 0003 -Create Date: 2026-07-16 -""" - -from typing import Sequence, Union -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects.postgresql import UUID - -revision: str = "0004" -down_revision: Union[str, None] = "0003" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Primary keys carry no server_default: the application generates UUIDv7 ids - # in Python (see pgdatabase.models._uuid7), matching the users table. - op.create_table( - "password_reset_tokens", - sa.Column("id", UUID(as_uuid=True), primary_key=True), - sa.Column( - "user_id", - UUID(as_uuid=True), - sa.ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("jti_hash", sa.String(), nullable=False, unique=True), - sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), - sa.Column( - "consumed", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - ) - op.create_index( - "ix_password_reset_tokens_user_id", - "password_reset_tokens", - ["user_id"], - ) - - -def downgrade() -> None: - op.drop_index( - "ix_password_reset_tokens_user_id", - table_name="password_reset_tokens", - ) - op.drop_table("password_reset_tokens") diff --git a/backend/alembic/versions/0005_add_knowledge_tables.py b/backend/alembic/versions/0005_add_knowledge_tables.py deleted file mode 100644 index bad09c0a..00000000 --- a/backend/alembic/versions/0005_add_knowledge_tables.py +++ /dev/null @@ -1,175 +0,0 @@ -"""add knowledge tables and tour_completed - -Revision ID: 0005 -Revises: 0004 -Create Date: 2026-07-17 19:00:00.000000 - -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects.postgresql import UUID - -revision: str = "0005" -down_revision: Union[str, None] = "0004" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """ - Add the tour completion flag and knowledge-related tables to the database. - - The migration creates tables for verified questions and answers, glossary terms, - catalog metadata, joins, synonyms, and value mappings, with indexes scoped by - user and database identifier. - """ - op.add_column( - "users", - sa.Column( - "tour_completed", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - ) - - op.create_table( - "verified_qas", - sa.Column("id", UUID(as_uuid=True), primary_key=True), - sa.Column( - "user_id", - UUID(as_uuid=True), - sa.ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("db_id", sa.String(), nullable=False), - sa.Column("question", sa.Text(), nullable=False), - sa.Column("sql", sa.Text(), nullable=False, server_default=""), - sa.Column("restatement", sa.Text(), nullable=False, server_default=""), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), - ) - op.create_index("ix_verified_qa_user_db", "verified_qas", ["user_id", "db_id"]) - op.create_unique_constraint( - "uq_verified_qa_user_db_question", - "verified_qas", - ["user_id", "db_id", "question"], - ) - - op.create_table( - "glossary_terms", - sa.Column("id", UUID(as_uuid=True), primary_key=True), - sa.Column( - "user_id", - UUID(as_uuid=True), - sa.ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("db_id", sa.String(), nullable=False), - sa.Column("term", sa.String(), nullable=False), - sa.Column("maps_to", sa.String(), nullable=False), - sa.Column("sql_hint", sa.String(), nullable=False, server_default=""), - ) - op.create_index("ix_glossary_user_db", "glossary_terms", ["user_id", "db_id"]) - - op.create_table( - "catalog_columns", - sa.Column("id", UUID(as_uuid=True), primary_key=True), - sa.Column( - "user_id", - UUID(as_uuid=True), - sa.ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("db_id", sa.String(), nullable=False), - sa.Column("table_name", sa.String(), nullable=False), - sa.Column("column_name", sa.String(), nullable=False), - sa.Column("description", sa.Text(), nullable=False), - ) - op.create_index("ix_cat_col_user_db", "catalog_columns", ["user_id", "db_id"]) - - op.create_table( - "catalog_metrics", - sa.Column("id", UUID(as_uuid=True), primary_key=True), - sa.Column( - "user_id", - UUID(as_uuid=True), - sa.ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("db_id", sa.String(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("description", sa.Text(), nullable=False, server_default=""), - sa.Column("sql_expression", sa.Text(), nullable=False), - ) - op.create_index("ix_cat_met_user_db", "catalog_metrics", ["user_id", "db_id"]) - - op.create_table( - "catalog_joins", - sa.Column("id", UUID(as_uuid=True), primary_key=True), - sa.Column( - "user_id", - UUID(as_uuid=True), - sa.ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("db_id", sa.String(), nullable=False), - sa.Column("tables", sa.String(), nullable=False), - sa.Column("join_condition", sa.Text(), nullable=False), - sa.Column("description", sa.Text(), nullable=False, server_default=""), - ) - op.create_index("ix_cat_join_user_db", "catalog_joins", ["user_id", "db_id"]) - - op.create_table( - "catalog_synonyms", - sa.Column("id", UUID(as_uuid=True), primary_key=True), - sa.Column( - "user_id", - UUID(as_uuid=True), - sa.ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("db_id", sa.String(), nullable=False), - sa.Column("term", sa.String(), nullable=False), - sa.Column("entity_type", sa.String(), nullable=False), - sa.Column("entity_name", sa.String(), nullable=False), - ) - op.create_index("ix_cat_syn_user_db", "catalog_synonyms", ["user_id", "db_id"]) - - op.create_table( - "catalog_value_mappings", - sa.Column("id", UUID(as_uuid=True), primary_key=True), - sa.Column( - "user_id", - UUID(as_uuid=True), - sa.ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("db_id", sa.String(), nullable=False), - sa.Column("table_name", sa.String(), nullable=False), - sa.Column("column_name", sa.String(), nullable=False), - sa.Column("db_value", sa.String(), nullable=False), - sa.Column("business_label", sa.String(), nullable=False), - ) - op.create_index( - "ix_cat_val_user_db", "catalog_value_mappings", ["user_id", "db_id"] - ) - - -def downgrade() -> None: - """ - Revert the schema changes introduced by this migration. - """ - op.drop_constraint( - "uq_verified_qa_user_db_question", "verified_qas", type_="unique" - ) - op.drop_table("catalog_value_mappings") - op.drop_table("catalog_synonyms") - op.drop_table("catalog_joins") - op.drop_table("catalog_metrics") - op.drop_table("catalog_columns") - op.drop_table("glossary_terms") - op.drop_table("verified_qas") - op.drop_column("users", "tour_completed") diff --git a/backend/alembic/versions/32e55b8f71d4_initial_schema_with_workspaces_and_.py b/backend/alembic/versions/32e55b8f71d4_initial_schema_with_workspaces_and_.py new file mode 100644 index 00000000..cef0eea1 --- /dev/null +++ b/backend/alembic/versions/32e55b8f71d4_initial_schema_with_workspaces_and_.py @@ -0,0 +1,339 @@ +"""Initial schema with workspaces and multi DBs + +Revision ID: 32e55b8f71d4 +Revises: +Create Date: 2026-07-22 17:57:49.765868 +""" + +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "32e55b8f71d4" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "users", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("hashed_pass", sa.String(), nullable=False), + sa.Column("role", sa.String(), nullable=False), + sa.Column("google_id", sa.String(), nullable=True), + sa.Column("supabase_id", sa.String(), nullable=True), + sa.Column("email_verified", sa.Boolean(), nullable=False), + sa.Column("first_name", sa.String(), nullable=True), + sa.Column("last_name", sa.String(), nullable=True), + sa.Column("avatar_url", sa.String(), nullable=True), + sa.Column("tour_completed", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + sa.UniqueConstraint("google_id"), + sa.UniqueConstraint("supabase_id"), + ) + op.create_table( + "otp_codes", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("code_hash", sa.String(), nullable=False), + sa.Column("purpose", sa.String(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("used", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "purpose", name="uq_otp_user_purpose"), + ) + op.create_table( + "password_reset_tokens", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("jti_hash", sa.String(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("consumed", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("jti_hash"), + ) + op.create_table( + "workspaces", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("slug", sa.String(), nullable=False), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("slug"), + ) + op.create_table( + "catalog_columns", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("db_id", sa.String(), nullable=False), + sa.Column("table_name", sa.String(), nullable=False), + sa.Column("column_name", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_cat_col_user_db", "catalog_columns", ["workspace_id", "db_id"], unique=False + ) + op.create_table( + "catalog_joins", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("db_id", sa.String(), nullable=False), + sa.Column("tables", sa.String(), nullable=False), + sa.Column("join_condition", sa.Text(), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_cat_join_user_db", "catalog_joins", ["workspace_id", "db_id"], unique=False + ) + op.create_table( + "catalog_metrics", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("db_id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("sql_expression", sa.Text(), nullable=False), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_cat_met_user_db", "catalog_metrics", ["workspace_id", "db_id"], unique=False + ) + op.create_table( + "catalog_synonyms", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("db_id", sa.String(), nullable=False), + sa.Column("term", sa.String(), nullable=False), + sa.Column("entity_type", sa.String(), nullable=False), + sa.Column("entity_name", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_cat_syn_user_db", + "catalog_synonyms", + ["workspace_id", "db_id"], + unique=False, + ) + op.create_table( + "catalog_value_mappings", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("db_id", sa.String(), nullable=False), + sa.Column("table_name", sa.String(), nullable=False), + sa.Column("column_name", sa.String(), nullable=False), + sa.Column("db_value", sa.String(), nullable=False), + sa.Column("business_label", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_cat_val_user_db", + "catalog_value_mappings", + ["workspace_id", "db_id"], + unique=False, + ) + op.create_table( + "conversations", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("database_id", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_conversations_workspace_updated", + "conversations", + ["workspace_id", "updated_at"], + unique=False, + ) + op.create_table( + "glossary_terms", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("db_id", sa.String(), nullable=False), + sa.Column("term", sa.String(), nullable=False), + sa.Column("maps_to", sa.String(), nullable=False), + sa.Column("sql_hint", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_glossary_user_db", "glossary_terms", ["workspace_id", "db_id"], unique=False + ) + op.create_table( + "recent_connections", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("db_url", sa.Text(), nullable=False), + sa.Column("display_url", sa.String(), nullable=False), + sa.Column("alias_name", sa.String(), nullable=True), + sa.Column("dialect", sa.String(), nullable=False), + sa.Column("db_id", sa.String(), nullable=False), + sa.Column("table_count", sa.Integer(), nullable=False), + sa.Column("connected_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("workspace_id", "db_id"), + ) + op.create_table( + "verified_qas", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("db_id", sa.String(), nullable=False), + sa.Column("question", sa.Text(), nullable=False), + sa.Column("sql", sa.Text(), nullable=False), + sa.Column("restatement", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "workspace_id", "db_id", "question", name="uq_verified_qa_user_db_question" + ), + ) + op.create_index( + "ix_verified_qa_user_db", + "verified_qas", + ["workspace_id", "db_id"], + unique=False, + ) + op.create_table( + "workspace_invites", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("role", sa.String(), nullable=False), + sa.Column("token", sa.String(), nullable=False), + sa.Column("invited_by", sa.UUID(), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint( + "role IN ('owner', 'admin', 'member')", name="ck_workspace_invite_role" + ), + sa.ForeignKeyConstraint(["invited_by"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token"), + sa.UniqueConstraint("workspace_id", "email", name="uq_workspace_invite_email"), + ) + op.create_table( + "workspace_members", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("role", sa.String(), nullable=False), + sa.Column("invited_by", sa.UUID(), nullable=True), + sa.Column("joined_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint( + "role IN ('owner', 'admin', 'member')", name="ck_workspace_member_role" + ), + sa.ForeignKeyConstraint(["invited_by"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("workspace_id", "user_id", name="uq_workspace_member"), + ) + op.create_index( + "ix_workspace_member_user", "workspace_members", ["user_id"], unique=False + ) + op.create_table( + "query_history", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("question", sa.Text(), nullable=False), + sa.Column("sql", sa.Text(), nullable=False), + sa.Column("result", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("confidence", sa.String(), nullable=False), + sa.Column("restatement", sa.Text(), nullable=False), + sa.Column("conversation_id", sa.UUID(), nullable=True), + sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["conversation_id"], ["conversations.id"], ondelete="SET NULL" + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_query_history_workspace_conv", + "query_history", + ["workspace_id", "conversation_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("ix_query_history_workspace_conv", table_name="query_history") + op.drop_table("query_history") + op.drop_index("ix_workspace_member_user", table_name="workspace_members") + op.drop_table("workspace_members") + op.drop_table("workspace_invites") + op.drop_index("ix_verified_qa_user_db", table_name="verified_qas") + op.drop_table("verified_qas") + op.drop_table("recent_connections") + op.drop_index("ix_glossary_user_db", table_name="glossary_terms") + op.drop_table("glossary_terms") + op.drop_index("ix_conversations_workspace_updated", table_name="conversations") + op.drop_table("conversations") + op.drop_index("ix_cat_val_user_db", table_name="catalog_value_mappings") + op.drop_table("catalog_value_mappings") + op.drop_index("ix_cat_syn_user_db", table_name="catalog_synonyms") + op.drop_table("catalog_synonyms") + op.drop_index("ix_cat_met_user_db", table_name="catalog_metrics") + op.drop_table("catalog_metrics") + op.drop_index("ix_cat_join_user_db", table_name="catalog_joins") + op.drop_table("catalog_joins") + op.drop_index("ix_cat_col_user_db", table_name="catalog_columns") + op.drop_table("catalog_columns") + op.drop_table("workspaces") + op.drop_table("password_reset_tokens") + op.drop_table("otp_codes") + op.drop_table("users") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/4b1d269a3ad8_add_workspace_activity_log.py b/backend/alembic/versions/4b1d269a3ad8_add_workspace_activity_log.py new file mode 100644 index 00000000..2b4a8484 --- /dev/null +++ b/backend/alembic/versions/4b1d269a3ad8_add_workspace_activity_log.py @@ -0,0 +1,63 @@ +"""Add workspace_activity_log + +Revision ID: 4b1d269a3ad8 +Revises: 32e55b8f71d4 +Create Date: 2026-07-22 19:34:32.909343 +""" + +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "4b1d269a3ad8" +down_revision: Union[str, None] = "32e55b8f71d4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "workspace_activity_log", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("actor_id", sa.UUID(), nullable=True), + sa.Column("event_type", sa.String(), nullable=False), + sa.Column("resource_type", sa.String(), nullable=False), + sa.Column("resource_id", sa.String(), nullable=True), + sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["actor_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_activity_workspace_date", + "workspace_activity_log", + ["workspace_id", "created_at"], + unique=False, + ) + op.create_index( + "ix_activity_workspace_event", + "workspace_activity_log", + ["workspace_id", "event_type"], + unique=False, + ) + op.alter_column("query_history", "user_id", existing_type=sa.UUID(), nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + # Rows may have been written with a NULL user_id while the column was + # nullable (streamed queries without an acting user). Restoring the NOT NULL + # constraint would fail against those rows, so drop them before the alter. + op.execute("DELETE FROM query_history WHERE user_id IS NULL") + op.alter_column("query_history", "user_id", existing_type=sa.UUID(), nullable=False) + op.drop_index("ix_activity_workspace_event", table_name="workspace_activity_log") + op.drop_index("ix_activity_workspace_date", table_name="workspace_activity_log") + op.drop_table("workspace_activity_log") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/__init__.py b/backend/alembic/versions/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/alembic/versions/a1f2c3d4e5b6_add_user_id_to_conversations.py b/backend/alembic/versions/a1f2c3d4e5b6_add_user_id_to_conversations.py new file mode 100644 index 00000000..0ff97845 --- /dev/null +++ b/backend/alembic/versions/a1f2c3d4e5b6_add_user_id_to_conversations.py @@ -0,0 +1,63 @@ +"""Add user_id to conversations (user + workspace scoping) + +Conversations become private to the user that created them, scoped within their +workspace. Existing conversations were workspace-shared and have no owner, so +they are cleared out (along with their query_history turns) before the NOT NULL +owner column is added. + +Revision ID: a1f2c3d4e5b6 +Revises: e8f901a23b4c +Create Date: 2026-07-24 00:00:00.000000 +""" + +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "a1f2c3d4e5b6" +down_revision: Union[str, None] = "e8f901a23b4c" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Legacy conversations were workspace-shared with no owner; there is no + # correct user to attribute them to, so clear them (and their turns) before + # adding the NOT NULL owner column. + op.execute("DELETE FROM query_history WHERE conversation_id IS NOT NULL") + op.execute("DELETE FROM conversations") + + op.add_column( + "conversations", + sa.Column("user_id", sa.UUID(), nullable=False), + ) + op.create_foreign_key( + "fk_conversations_user_id_users", + "conversations", + "users", + ["user_id"], + ["id"], + ondelete="CASCADE", + ) + + # Swap the list index to include the owner: the list query filters by + # (workspace_id, user_id) and orders by updated_at. Use IF EXISTS/IF NOT + # EXISTS so the swap is resilient to databases where the old index was + # never created (older initial-schema revisions did not have it). + op.execute("DROP INDEX IF EXISTS ix_conversations_workspace_updated") + op.execute( + "CREATE INDEX IF NOT EXISTS ix_conversations_workspace_user_updated " + "ON conversations (workspace_id, user_id, updated_at)" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_conversations_workspace_user_updated") + op.execute( + "CREATE INDEX IF NOT EXISTS ix_conversations_workspace_updated " + "ON conversations (workspace_id, updated_at)" + ) + op.drop_constraint( + "fk_conversations_user_id_users", "conversations", type_="foreignkey" + ) + op.drop_column("conversations", "user_id") diff --git a/backend/alembic/versions/b83664841390_add_user_metadata.py b/backend/alembic/versions/b83664841390_add_user_metadata.py new file mode 100644 index 00000000..87566159 --- /dev/null +++ b/backend/alembic/versions/b83664841390_add_user_metadata.py @@ -0,0 +1,92 @@ +"""Add user metadata column and dashboards, saved_queries, dashboard_panels tables + +Revision ID: b83664841390 +Revises: 4b1d269a3ad8 +Create Date: 2026-07-23 00:42:52.421777 +""" + +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "b83664841390" +down_revision: Union[str, None] = "4b1d269a3ad8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "dashboards", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "saved_queries", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("question", sa.String(), nullable=True), + sa.Column("sql", sa.String(), nullable=True), + sa.Column("columns", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("database_id", sa.String(), nullable=True), + sa.Column("visualization_type", sa.String(), nullable=True), + sa.Column("viz_config", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column( + "last_result", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "dashboard_panels", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("dashboard_id", sa.UUID(), nullable=False), + sa.Column("saved_query_id", sa.UUID(), nullable=True), + sa.Column("title", sa.String(length=255), nullable=True), + sa.Column("visualization_type", sa.String(), nullable=True), + sa.Column("viz_config", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("position", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["dashboard_id"], ["dashboards.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["saved_query_id"], ["saved_queries.id"], ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.add_column( + "users", + sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("users", "metadata") + op.drop_table("dashboard_panels") + op.drop_table("saved_queries") + op.drop_table("dashboards") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/c1a4d7e29b58_add_query_history_chart.py b/backend/alembic/versions/c1a4d7e29b58_add_query_history_chart.py new file mode 100644 index 00000000..29c3a4ab --- /dev/null +++ b/backend/alembic/versions/c1a4d7e29b58_add_query_history_chart.py @@ -0,0 +1,29 @@ +"""Add chart spec to query history + +Revision ID: c1a4d7e29b58 +Revises: b83664841390 +Create Date: 2026-07-23 14:10:00.000000 +""" + +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "c1a4d7e29b58" +down_revision: Union[str, None] = "b83664841390" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Nullable: turns recorded before the model started choosing a chart have + # none, and the UI falls back to its own heuristic for those. + op.add_column( + "query_history", + sa.Column("chart", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("query_history", "chart") diff --git a/backend/alembic/versions/e8f901a23b4c_add_workspace_settings_table.py b/backend/alembic/versions/e8f901a23b4c_add_workspace_settings_table.py new file mode 100644 index 00000000..e881f14f --- /dev/null +++ b/backend/alembic/versions/e8f901a23b4c_add_workspace_settings_table.py @@ -0,0 +1,67 @@ +"""add_workspace_settings_table + +Revision ID: e8f901a23b4c +Revises: c1a4d7e29b58 +Create Date: 2026-07-23 18:30:00.000000 +""" + +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "e8f901a23b4c" +down_revision: Union[str, None] = "c1a4d7e29b58" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "workspace_settings", + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column( + "default_invite_role", + sa.String(length=20), + server_default="member", + nullable=False, + ), + sa.Column( + "invite_expiry_days", + sa.Integer(), + server_default="7", + nullable=False, + ), + sa.Column( + "activity_retention_days", + sa.Integer(), + server_default="30", + nullable=False, + ), + sa.Column( + "role_permissions", + postgresql.JSONB(astext_type=sa.Text()), + server_default="{}", + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["workspace_id"], ["workspaces.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("workspace_id"), + ) + + +def downgrade() -> None: + op.drop_table("workspace_settings") diff --git a/backend/app/config.py b/backend/app/config.py index 9b6b3ac2..7ccad2b5 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,26 +1,55 @@ -"""Local config + path constants. +"""Runtime configuration, held in memory only. -BoloDB uses OpenRouter (deepseek-v4-flash) for every AI operation. The API key -is read from the OPENROUTER_API_KEY environment variable at startup and -shared across all users — no per-user key storage or encryption is needed. +BoloDB keeps no local state on disk. Secrets come from the environment, and +everything a user creates lives in Postgres. The only file the app writes is the +sample database, and that is a regenerable cache under the repo's ``data/`` +directory (see ``backend/sample_data.py``), not configuration. -Stored at ~/.bolodb/config.json. Only non-sensitive settings (last connected -database URL) are persisted; the API key is never written to disk. +``cfg`` is a small per-process dict: the OpenRouter key (always read fresh from +the environment) and the last connected database URL (a convenience value that +lives only for the process lifetime). Nothing here is persisted. """ -import json import logging import os -from pathlib import Path - -from cryptography.fernet import Fernet, InvalidToken log = logging.getLogger(__name__) -CONFIG_DIR = Path(os.path.expanduser("~")) / ".bolodb" -CONFIG_FILE = CONFIG_DIR / "config.json" -SECRET_FILE = CONFIG_DIR / ".secret" -_DB_URL_KEY_FILE = CONFIG_DIR / "db_url.key" + +def _env_number(name, default, cast, minimum=None): + """Read a numeric env var without ever crashing process startup. + + A malformed value (e.g. ``ACTIVITY_CLEANUP_INTERVAL_HOURS=daily``) must not + abort import and take every route down with it — fall back to the default + and warn. ``minimum`` clamps the result so a misconfigured 0/negative + interval can't turn the cleanup loop into a busy loop. + """ + raw = os.environ.get(name) + if raw is None: + value = cast(default) + else: + try: + value = cast(raw) + except (TypeError, ValueError): + log.warning("Invalid %s=%r; falling back to default %r", name, raw, default) + value = cast(default) + if minimum is not None: + value = max(minimum, value) + return value + + +ACTIVITY_LOG_RETENTION_DAYS = _env_number( + "ACTIVITY_LOG_RETENTION_DAYS", 30, int, minimum=1 +) +# Periodic pruning of activity rows past the retention window. Safe to run +# in-process because BoloDB is deployed single-process; set the flag to "false" +# if that ever stops being true and the job moves to a dedicated worker. +ACTIVITY_CLEANUP_ENABLED = os.environ.get( + "ACTIVITY_CLEANUP_ENABLED", "true" +).lower() not in ("false", "0", "no") +ACTIVITY_CLEANUP_INTERVAL_HOURS = _env_number( + "ACTIVITY_CLEANUP_INTERVAL_HOURS", 24, float, minimum=0.1 +) DEFAULTS = { "openrouter_key": "", @@ -32,85 +61,26 @@ def default_config(): return dict(DEFAULTS) -def ensure_dir(): - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - try: - os.chmod(CONFIG_DIR, 0o700) - except OSError: - pass - - def load_config(): - ensure_dir() - d = {} - if CONFIG_FILE.exists(): - try: - d = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) - except Exception: - pass - if not isinstance(d, dict): - d = {} - - # Strip legacy fields that no longer apply - changed = False - for key in ("provider", "model", "api_keys"): - if key in d: - d.pop(key) - changed = True - - cfg = {**default_config(), **d} - # API key is always read fresh from env, never from disk + """Build the in-memory config from the environment. Reads no files.""" + cfg = default_config() + # The API key is always read fresh from the environment, never stored. cfg["openrouter_key"] = os.environ.get("OPENROUTER_API_KEY", "") - - # Persist sanitized config if legacy fields were removed - if changed: - save_config(cfg) - return cfg def save_config(cfg): - ensure_dir() - # Never persist the API key - to_save = {k: v for k, v in cfg.items() if k != "openrouter_key"} - CONFIG_FILE.write_text(json.dumps(to_save, indent=2), encoding="utf-8") - try: - os.chmod(CONFIG_FILE, 0o600) - except OSError: - pass - - -def _db_url_fernet(): - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - import base64 - import hashlib - - if _DB_URL_KEY_FILE.exists(): - secret = _DB_URL_KEY_FILE.read_text().strip() - else: - secret = base64.urlsafe_b64encode(os.urandom(32)).decode() - _DB_URL_KEY_FILE.write_text(secret) - key = base64.urlsafe_b64encode(hashlib.sha256(secret.encode()).digest()) - return Fernet(key) - - -def encrypt_db_url(url): - if not url: - return url - return _db_url_fernet().encrypt(url.encode()).decode() - + """No-op: config is in-memory only. -def decrypt_db_url(value): - if not value: - return value - try: - return _db_url_fernet().decrypt(value.encode()).decode() - except (InvalidToken, ValueError, TypeError): - return value + Kept so callers that update `last_db_url` don't have to special-case + persistence — mutating the shared `cfg` dict is the whole effect, and it + lasts exactly as long as the process, which is all that value needs. + """ + return None def public_config(cfg): """Config as exposed to the frontend — never includes the API key.""" return { - "last_db_url": decrypt_db_url(cfg.get("last_db_url", "")), + "last_db_url": cfg.get("last_db_url", ""), } diff --git a/backend/app/controllers/activity.py b/backend/app/controllers/activity.py new file mode 100644 index 00000000..95d446c9 --- /dev/null +++ b/backend/app/controllers/activity.py @@ -0,0 +1,186 @@ +import asyncio +import logging +from sqlalchemy import select, desc, delete, func +from datetime import datetime, timedelta, timezone +from backend.app.pgdatabase.engine import async_session +from backend.app.models.workspace_settings import WorkspaceSettings +from backend.app.models.activity import WorkspaceActivityLog +from backend.app.models.orm_user import User +from backend.app.pgdatabase.serialization import _to_uuid +from backend.app.config import ( + ACTIVITY_CLEANUP_INTERVAL_HOURS, + ACTIVITY_LOG_RETENTION_DAYS, +) + +log = logging.getLogger(__name__) + + +async def log_activity( + workspace_id: str, + actor_id: str | None, + event_type: str, + resource_type: str, + resource_id: str = None, + metadata: dict = None, +): + try: + wid = _to_uuid(workspace_id) + aid = _to_uuid(actor_id) if actor_id else None + except ValueError: + return + + async with async_session() as session: + log = WorkspaceActivityLog( + workspace_id=wid, + actor_id=aid, + event_type=event_type, + resource_type=resource_type, + resource_id=resource_id, + metadata_=metadata or {}, + ) + session.add(log) + await session.commit() + + +async def get_workspace_activity( + workspace_id: str, limit: int = 50, offset: int = 0, event_type: str = None +): + try: + wid = _to_uuid(workspace_id) + except ValueError: + return [] + + async with async_session() as session: + settings_res = await session.execute( + select(WorkspaceSettings).where(WorkspaceSettings.workspace_id == wid) + ) + settings = settings_res.scalar_one_or_none() + retention_days = getattr( + settings, "activity_retention_days", ACTIVITY_LOG_RETENTION_DAYS + ) + + cutoff_date = datetime.now(timezone.utc) - timedelta(days=retention_days) + + stmt = ( + select(WorkspaceActivityLog, User.email) + .outerjoin(User, WorkspaceActivityLog.actor_id == User.id) + .where(WorkspaceActivityLog.workspace_id == wid) + .where(WorkspaceActivityLog.created_at >= cutoff_date) + ) + + if event_type: + stmt = stmt.where(WorkspaceActivityLog.event_type == event_type) + + stmt = ( + stmt.order_by(desc(WorkspaceActivityLog.created_at)) + .limit(limit) + .offset(offset) + ) + result = await session.execute(stmt) + rows = result.all() + + return [ + { + "id": str(log.id), + "workspace_id": str(log.workspace_id), + "actor_id": str(log.actor_id) if log.actor_id else None, + "actor_email": email, + "event_type": log.event_type, + "resource_type": log.resource_type, + "resource_id": log.resource_id, + "metadata_": log.metadata_, + "created_at": log.created_at, + } + for log, email in rows + ] + + +# The UI route caps a page at 100 rows; an export should cover the whole +# retention window, so it pages through instead of taking a single big limit. +EXPORT_PAGE_SIZE = 500 +EXPORT_MAX_ROWS = 50_000 + + +async def iter_workspace_activity(workspace_id: str, event_type: str = None): + """Yield every retained activity row for a workspace, newest first.""" + offset = 0 + while offset < EXPORT_MAX_ROWS: + batch = await get_workspace_activity( + workspace_id, + limit=EXPORT_PAGE_SIZE, + offset=offset, + event_type=event_type, + ) + if not batch: + return + for row in batch: + yield row + if len(batch) < EXPORT_PAGE_SIZE: + return + offset += EXPORT_PAGE_SIZE + + +async def delete_old_activity(retention_days: int | None = None) -> int: + """Prune activity rows past the retention window. + + Reads beyond the window are already filtered out by + ``get_workspace_activity``; this reclaims the storage behind them. + + When ``retention_days`` is given it overrides every workspace's window. + Otherwise each workspace is pruned by its own ``activity_retention_days`` + setting, falling back to ``ACTIVITY_LOG_RETENTION_DAYS`` for workspaces + without a settings row. Returns the number of rows removed. + """ + async with async_session() as session: + if retention_days is not None: + cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) + stmt = delete(WorkspaceActivityLog).where( + WorkspaceActivityLog.created_at < cutoff + ) + else: + retention = func.coalesce( + select(WorkspaceSettings.activity_retention_days) + .where( + WorkspaceSettings.workspace_id == WorkspaceActivityLog.workspace_id + ) + .scalar_subquery(), + ACTIVITY_LOG_RETENTION_DAYS, + ) + stmt = delete(WorkspaceActivityLog).where( + WorkspaceActivityLog.created_at + < func.now() - func.make_interval(0, 0, 0, retention) + ) + + result = await session.execute(stmt) + await session.commit() + return result.rowcount or 0 + + +async def cleanup_old_activity_logs(db=None, retention_days: int | None = None) -> int: + """Alias function delegating to ``delete_old_activity`` or returning deleted count.""" + if isinstance(db, int) and retention_days is None: + retention_days = db + return await delete_old_activity(retention_days=retention_days) + + +async def activity_cleanup_loop(interval_hours: float | None = None): + """Run ``delete_old_activity`` forever on a fixed interval. + + Started from the app lifespan. Every failure is swallowed and retried on the + next tick — a pruning job must never be able to take the server down. + """ + interval = ( + interval_hours + if interval_hours is not None + else ACTIVITY_CLEANUP_INTERVAL_HOURS + ) * 3600 + while True: + try: + removed = await delete_old_activity() + if removed: + log.info("activity cleanup removed %d expired rows", removed) + except asyncio.CancelledError: + raise + except Exception: + log.exception("activity cleanup failed; retrying next interval") + await asyncio.sleep(interval) diff --git a/backend/app/controllers/auth.py b/backend/app/controllers/auth.py index 7e2f81e8..4b168a87 100644 --- a/backend/app/controllers/auth.py +++ b/backend/app/controllers/auth.py @@ -6,6 +6,7 @@ from pydantic import EmailStr from fastapi import HTTPException from backend.app.models.user import ( + UpdateProfile, UserSignup, UserInDB, Role, @@ -19,7 +20,7 @@ update_user, UserAlreadyExistsError, ) -from backend.app.pgdatabase.models import PasswordResetToken +from backend.app.models.auth_token import PasswordResetToken from backend.app.pgdatabase.engine import get_engine import jwt from jwt import PyJWKClient @@ -56,6 +57,41 @@ async def get_me(user_id): return data +async def update_profile(user_id: str, req: UpdateProfile): + fields = req.model_dump(mode="json", exclude_unset=True) + new_email = fields.get("email") + + if new_email: + existing = await get_user_by_email(new_email) + if existing and str(existing["_id"]) != str(user_id): + raise HTTPException( + status_code=409, detail="An account with this email already exists" + ) + + current = await get_me(user_id) + if current and current.get("email") != new_email: + fields["email_verified"] = False + + if fields: + from sqlalchemy.exc import IntegrityError + + try: + await update_user(user_id, **fields) + except (IntegrityError, UserAlreadyExistsError): + raise HTTPException( + status_code=409, detail="An account with this email already exists" + ) + + if new_email and fields.get("email_verified") is False: + from backend.app.pgdatabase.otp import create_otp + from backend.app.services.email import send_verification_email + + otp_code = await create_otp(user_id, purpose="signup") + await send_verification_email(new_email, otp_code) + + return await get_me(user_id) + + async def login(email: EmailStr, password: str): user_details = await get_user_by_email(email) if user_details is None: diff --git a/backend/app/controllers/catalog.py b/backend/app/controllers/catalog.py index 8306783e..23a00ddb 100644 --- a/backend/app/controllers/catalog.py +++ b/backend/app/controllers/catalog.py @@ -11,31 +11,35 @@ log = logging.getLogger(__name__) -async def get_catalog(user_id, db, kb): +async def get_catalog(workspace_id, db, kb, db_id=None): """Return the stored catalog for the user's connected database.""" - if not db.connected(user_id): + if not db.connected(workspace_id, db_id): raise HTTPException(409, "No database connected") - return {"catalog": await kb.get_catalog(user_id, db.get_db_id(user_id))} + return { + "catalog": await kb.get_catalog(workspace_id, db.get_db_id(workspace_id, db_id)) + } -async def save_catalog(user_id, db, kb, payload): +async def save_catalog(workspace_id, db, kb, payload, db_id=None): """Persist ``payload`` as the connected database's catalog.""" - if not db.connected(user_id): + if not db.connected(workspace_id, db_id): raise HTTPException(409, "No database connected") - await kb.set_catalog(user_id, db.get_db_id(user_id), payload.model_dump()) + await kb.set_catalog( + workspace_id, db.get_db_id(workspace_id, db_id), payload.model_dump() + ) return {"ok": True} -async def suggest(user_id, db, providers): +async def suggest(workspace_id, db, providers, db_id=None): """Suggest a catalog: the deterministic backbone (joins + value maps from the schema) enriched by the LLM (descriptions, metrics, synonyms, labels). The LLM step degrades gracefully — a failure still returns the backbone.""" - if not db.connected(user_id): + if not db.connected(workspace_id, db_id): raise HTTPException(409, "No database connected") - base = suggest_from_schema(db.get_schema(user_id)) + base = suggest_from_schema(db.get_schema(workspace_id, db_id=db_id)) try: enriched = await llm_suggest_catalog( - providers.get(user_id), db.schema_as_text(user_id) + providers.get(workspace_id), db.schema_as_text(workspace_id, db_id) ) except Exception: # Graceful degradation: still return the schema-only backbone, but log diff --git a/backend/app/controllers/conversations.py b/backend/app/controllers/conversations.py index 87b3d24c..bd487d81 100644 --- a/backend/app/controllers/conversations.py +++ b/backend/app/controllers/conversations.py @@ -5,25 +5,27 @@ log = logging.getLogger(__name__) -async def list_conversations(user_id): - return await mdb.get_conversations(user_id) +async def list_conversations(workspace_id, user_id): + return await mdb.get_conversations(workspace_id, user_id) -async def create_conversation(user_id, title="", database_id=None): - return await mdb.create_conversation(user_id, title=title, database_id=database_id) +async def create_conversation(workspace_id, user_id, title="", database_id=None): + return await mdb.create_conversation( + workspace_id, user_id, title=title, database_id=database_id + ) -async def get_conversation(user_id, conversation_id): - return await mdb.get_conversation(user_id, conversation_id) +async def get_conversation(workspace_id, user_id, conversation_id): + return await mdb.get_conversation(workspace_id, user_id, conversation_id) -async def rename_conversation(user_id, conversation_id, title): - return await mdb.rename_conversation(user_id, conversation_id, title) +async def rename_conversation(workspace_id, user_id, conversation_id, title): + return await mdb.rename_conversation(workspace_id, user_id, conversation_id, title) -async def delete_conversation(user_id, conversation_id): - return await mdb.delete_conversation(user_id, conversation_id) +async def delete_conversation(workspace_id, user_id, conversation_id): + return await mdb.delete_conversation(workspace_id, user_id, conversation_id) -async def clear_conversations(user_id): - return await mdb.clear_conversations(user_id) +async def clear_conversations(workspace_id, user_id): + return await mdb.clear_conversations(workspace_id, user_id) diff --git a/backend/app/controllers/dashboards.py b/backend/app/controllers/dashboards.py new file mode 100644 index 00000000..1e33108e --- /dev/null +++ b/backend/app/controllers/dashboards.py @@ -0,0 +1,174 @@ +import logging +import asyncio +from backend.app.pgdatabase import dashboards as mdb_dash +from backend.app.pgdatabase import saved_queries as mdb_sq + +log = logging.getLogger(__name__) + +# --- Saved Queries --- + + +async def create_saved_query(workspace_id: str, created_by: str, data: dict): + return await mdb_sq.create_saved_query(workspace_id, created_by, **data) + + +async def list_saved_queries(workspace_id: str, limit: int = 50, offset: int = 0): + return await mdb_sq.list_saved_queries(workspace_id, limit, offset) + + +async def get_saved_query(workspace_id: str, query_id: str): + return await mdb_sq.get_saved_query(workspace_id, query_id) + + +async def update_saved_query(workspace_id: str, query_id: str, data: dict): + return await mdb_sq.update_saved_query(workspace_id, query_id, **data) + + +async def delete_saved_query(workspace_id: str, query_id: str): + return await mdb_sq.delete_saved_query(workspace_id, query_id) + + +# --- Dashboards --- + + +async def create_dashboard( + workspace_id: str, created_by: str, name: str, description: str = None +): + return await mdb_dash.create_dashboard(workspace_id, created_by, name, description) + + +async def list_dashboards(workspace_id: str): + return await mdb_dash.list_dashboards(workspace_id) + + +async def get_dashboard(workspace_id: str, dashboard_id: str): + return await mdb_dash.get_dashboard(workspace_id, dashboard_id) + + +async def update_dashboard(workspace_id: str, dashboard_id: str, data: dict): + return await mdb_dash.update_dashboard(workspace_id, dashboard_id, **data) + + +async def delete_dashboard(workspace_id: str, dashboard_id: str): + return await mdb_dash.delete_dashboard(workspace_id, dashboard_id) + + +# --- Panels --- + + +async def add_panel(workspace_id: str, dashboard_id: str, data: dict): + return await mdb_dash.add_panel(workspace_id, dashboard_id, **data) + + +async def update_panel(workspace_id: str, dashboard_id: str, panel_id: str, data: dict): + return await mdb_dash.update_panel(workspace_id, dashboard_id, panel_id, **data) + + +async def delete_panel(workspace_id: str, dashboard_id: str, panel_id: str): + return await mdb_dash.delete_panel(workspace_id, dashboard_id, panel_id) + + +async def update_panels_batch(workspace_id: str, dashboard_id: str, updates: list): + return await mdb_dash.update_panels_batch(workspace_id, dashboard_id, updates) + + +# --- Execution --- + + +def _normalize_columns(columns, rows): + """ + Return columns as [{"name", "type_name"}] whatever shape they were stored in. + + Saved queries persist `columns` as a plain list of names (that is what the + chat turn carries), while a fresh execution only knows the names too. The + dashboard renderer picks its value axis by type, so infer the type from the + values actually returned rather than handing back bare strings. + """ + out = [] + for col in columns or []: + if isinstance(col, dict): + name = col.get("name") + type_name = col.get("type_name") + else: + name = col + type_name = None + if name is None: + continue + if not type_name: + type_name = _infer_type(name, rows) + out.append({"name": name, "type_name": type_name}) + return out + + +def _infer_type(name, rows): + for row in (rows or [])[:20]: + val = row.get(name) if isinstance(row, dict) else None + if val is None or val == "": + continue + if isinstance(val, bool): + return "string" + if isinstance(val, int): + return "integer" + if isinstance(val, float): + return "float" + try: + float(str(val).replace(",", "").strip()) + return "float" + except (TypeError, ValueError): + return "string" + return "string" + + +async def execute_dashboard_queries(workspace_id: str, dashboard_id: str, db_manager): + dash = await mdb_dash.get_dashboard(workspace_id, dashboard_id) + if not dash: + return None + + panel_sq_ids = [] + for p in dash["panels"]: + if p.get("saved_query_id"): + panel_sq_ids.append(p["saved_query_id"]) + + results = {} + sem = asyncio.Semaphore(5) + + async def run_sq(sq_id): + sq = await mdb_sq.get_saved_query(workspace_id, str(sq_id)) + if not sq: + return None + sq_key = str(sq.get("id") or sq.get("_id") or sq_id) + try: + sql = sq.get("sql") + target_db_id = sq.get("database_id") + if sql: + async with sem: + data = await asyncio.to_thread( + db_manager.execute, workspace_id, sql, target_db_id + ) + + # data comes back as dict: {"columns": [], "rows": [{}], "error": ""} + if data.get("error"): + return {"id": sq_key, "error": data["error"]} + + # Charts assume array of objects keyed by column name + rows = data.get("rows", [])[:500] + # Trust the live result's column list — a saved query's stored + # columns can be stale if the underlying SQL was edited. + return { + "id": sq_key, + "rows": rows, + "columns": _normalize_columns(data.get("columns", []), rows), + } + except Exception as e: + log.warning(f"Dashboard panel query failed sq_id={sq_id}: {e}") + return {"id": sq_key, "error": str(e)} + return None + + tasks = [run_sq(sq_id) for sq_id in set(panel_sq_ids)] + fetched = await asyncio.gather(*tasks) + + for res in fetched: + if res: + results[res["id"]] = res + + return results diff --git a/backend/app/controllers/database.py b/backend/app/controllers/database.py index b8838d01..d3113a0a 100644 --- a/backend/app/controllers/database.py +++ b/backend/app/controllers/database.py @@ -1,101 +1,248 @@ from fastapi import HTTPException -from backend.app.database import sanitize_url -from backend.sample_data import ensure_sample_db +from backend.app.database import sanitize_url, db_id_for +from backend.sample_data import ensure_sample_db, sample_db_path import backend.app.pgdatabase as mdb +from backend.app.pgdatabase.connections import ConnectionKeyError import logging +from backend.app.controllers.activity import log_activity logger = logging.getLogger(__name__) +# A server-misconfiguration message, distinct from a transient save failure: +# every save fails the same way until an operator sets RECENT_CONNECTIONS_KEY, +# and reconnecting existing databases fails too — so "try again" would mislead. +_KEY_ERROR_MESSAGE = ( + "Connected, but saved connections aren't configured on the server " + "(RECENT_CONNECTIONS_KEY is missing). Ask an administrator to set it — " + "until then this database won't be remembered." +) -async def connect(db, kb, cfg, req_data, user_id=None): +# Shown wherever a database needs a name — header, switcher, connect screen. +SAMPLE_ALIAS = "Sample Webshop" + + +def _call_with_db_id(fn, workspace_id, db_id): + """Call a DatabaseManager accessor, tolerating single-database managers. + + Not every manager the app is handed takes a `db_id` (test doubles, and the + single-connection manager this grew out of), so fall back to the one-argument + form rather than requiring every implementation to grow the parameter. """ - Connects to a database and enriches the connection details with knowledge-base information. + if db_id is not None: + try: + return fn(workspace_id, db_id=db_id) + except TypeError: + pass + try: + return fn(workspace_id, db_id) + except TypeError: + pass + return fn(workspace_id) - Parameters: - req_data: Request data containing the database URL. - user_id: Optional identifier of the user establishing the connection. - Returns: - The connection result with trust, glossary, knowledge availability, and starter questions. +def _sample_db_id() -> str: + """The db_id the sample database connects under, without building it. - Raises: - HTTPException: If the database connection fails. + `db_id` is a hash of the connection URL, so this matches whatever + `db.connect(ensure_sample_db())` would produce — letting us recognise a + request for the sample before deciding whether to rebuild it. """ - result = db.connect(user_id, req_data.db_url) + return db_id_for(f"sqlite:///{sample_db_path().as_posix()}") + + +def _connect_sample_url(db, workspace_id): + """Build (if needed) and connect the sample database. Returns its db_id.""" + url = ensure_sample_db() + result = db.connect(workspace_id, url) + if not result["ok"]: + logger.warning("Could not connect the sample database: %s", result["error"]) + return None + logger.info("Restored the sample database for workspace") + return result["db_id"] + + +async def ensure_connection(db, workspace_id, db_id=None): + """Return the db_id this workspace is connected to, reconnecting if needed. + + Live engines live in process memory, so a restart, a redeploy or a request + landing on a different worker used to look exactly like the user having + disconnected — they were bounced back to the connect screen with their + database apparently gone. The credentials are already stored against the + workspace, so re-establish the connection from them instead. + + Returns None when the workspace has no usable stored connection. + """ + if not workspace_id: + return None + if _call_with_db_id(db.connected, workspace_id, db_id): + # An explicitly requested database that is connected *is* the answer — + # asking the manager to name it again would let a manager that ignores + # db_id hand back the workspace default instead. + return db_id or db.get_db_id(workspace_id) + + try: + if db_id: + conn = await mdb.get_recent_connection_by_db_id(workspace_id, db_id) + else: + conn = await mdb.get_latest_recent_connection(workspace_id) + except RuntimeError: + # Undecryptable stored credentials — the user has to re-add the + # connection, and /api/reconnect reports that explicitly. + logger.exception("Could not decrypt stored connection for workspace") + conn = None + + if not conn or not conn.get("db_url"): + # The sample database needs no stored credentials — it's regenerable + # from the vendored dump. So when the request explicitly targets the + # sample and there's nothing to restore, rebuild it rather than + # reporting no database. This keeps "try the sample" working with zero + # configuration, even if encryption isn't set up or the worker + # restarted and lost the live connection. A blank db_id is left alone so + # nothing silently auto-connects the sample. + if db_id and db_id == _sample_db_id(): + return _connect_sample_url(db, workspace_id) + return None + + result = db.connect(workspace_id, conn["db_url"]) + if not result["ok"]: + logger.warning( + "Could not restore connection %s: %s", conn["db_id"], result["error"] + ) + return None + logger.info("Restored stored connection %s from saved credentials", result["db_id"]) + return result["db_id"] + + +async def connect(db, kb, cfg, req_data, workspace_id=None, user_id=None): + result = db.connect(workspace_id, req_data.db_url) if not result["ok"]: raise HTTPException(400, result["error"]) db_id = result["db_id"] - result["trust"] = await kb.trust_level(user_id, db_id) - result["glossary"] = await kb.get_glossary(user_id, db_id) - result["has_knowledge"] = (await kb.count_verified(user_id, db_id)) > 0 + result["trust"] = await kb.trust_level(workspace_id, db_id) + result["glossary"] = await kb.get_glossary(workspace_id, db_id) + result["has_knowledge"] = (await kb.count_verified(workspace_id, db_id)) > 0 result["starters"] = [ - v["question"] for v in (await kb.get_verified(user_id, db_id))[:6] + v["question"] for v in (await kb.get_verified(workspace_id, db_id))[:6] ] - if user_id: + alias_name = getattr(req_data, "alias_name", None) or None + result["alias_name"] = alias_name + + if workspace_id: try: await mdb.save_recent_connection( - user_id=user_id, + workspace_id=workspace_id, db_url=req_data.db_url, display_url=sanitize_url(req_data.db_url), dialect=result["dialect"], db_id=result["db_id"], table_count=result["tables"], + alias_name=alias_name, + ) + if not alias_name: + # An existing row keeps its alias when we don't send one, so + # read it back — the header and switcher label from this. + saved = await mdb.get_recent_connection_by_db_id(workspace_id, db_id) + result["alias_name"] = saved.get("alias_name") if saved else None + except ConnectionKeyError: + logger.exception("Recent-connection encryption key is not configured") + result["save_error"] = _KEY_ERROR_MESSAGE + except Exception: + # The connection itself is live; only its persistence failed. Say so + # rather than letting it quietly vanish from the connect screen. + logger.exception("Failed to save recent connection") + result["save_error"] = ( + "Connected, but this database couldn't be saved to your workspace — " + "you may have to reconnect it next time." ) - except Exception as e: - logger.warning("Failed to save recent connection: %s", e) + await log_activity( + workspace_id, + user_id, + "db.connected", + "connection", + str(result.get("db_id", "")), + {"db_url": sanitize_url(req_data.db_url), "dialect": result.get("dialect")}, + ) return result -async def connect_sample(db, kb, cfg, user_id=None): +async def connect_sample(db, kb, cfg, workspace_id=None, user_id=None): """ Connect to the sample database and return its connection details and knowledge metadata. Parameters: - user_id: Optional identifier of the user establishing the connection. + workspace_id: Identifier of the workspace establishing the connection. Returns: dict: Connection details enriched with trust, glossary, knowledge availability, starter questions, and a sample-database flag. """ url = ensure_sample_db() - result = db.connect(user_id, url) + result = db.connect(workspace_id, url) if not result["ok"]: raise HTTPException(500, result["error"]) - dbid = db.get_db_id(user_id) - await kb.seed_sample(user_id, dbid) + dbid = db.get_db_id(workspace_id) + await kb.seed_sample(workspace_id, dbid) - result["trust"] = await kb.trust_level(user_id, dbid) - result["glossary"] = await kb.get_glossary(user_id, dbid) - result["has_knowledge"] = (await kb.count_verified(user_id, dbid)) > 0 + result["trust"] = await kb.trust_level(workspace_id, dbid) + result["glossary"] = await kb.get_glossary(workspace_id, dbid) + result["has_knowledge"] = (await kb.count_verified(workspace_id, dbid)) > 0 result["starters"] = [ - v["question"] for v in (await kb.get_verified(user_id, dbid))[:6] + v["question"] for v in (await kb.get_verified(workspace_id, dbid))[:6] ] result["is_sample"] = True - - if user_id: + result["alias_name"] = SAMPLE_ALIAS + + if workspace_id: + # Preserve a custom alias the user set on a previous connect; only + # brand-new sample connections get the default SAMPLE_ALIAS. Mirrors + # connect()'s alias handling so reconnecting never clobbers a rename. + existing = await mdb.get_recent_connection_by_db_id( + workspace_id, result["db_id"] + ) + alias_name = (existing.get("alias_name") if existing else None) or SAMPLE_ALIAS + result["alias_name"] = alias_name try: await mdb.save_recent_connection( - user_id=user_id, + workspace_id=workspace_id, db_url=url, display_url=sanitize_url(url), dialect=result["dialect"], db_id=result["db_id"], table_count=result["tables"], + alias_name=alias_name, + ) + except ConnectionKeyError: + logger.exception("Recent-connection encryption key is not configured") + result["save_error"] = _KEY_ERROR_MESSAGE + except Exception: + logger.exception("Failed to save recent connection") + result["save_error"] = ( + "Connected, but the sample database couldn't be saved to your workspace." ) - except Exception as e: - logger.warning("Failed to save recent connection: %s", e) + await log_activity( + workspace_id, + user_id, + "db.connected", + "connection", + str(result.get("db_id", "")), + {"is_sample": True, "dialect": result.get("dialect")}, + ) return result -async def disconnect(user_id, db, cfg): - db.disconnect(user_id) +async def disconnect(workspace_id, db, cfg, db_id=None, user_id=None): + db.disconnect(workspace_id, db_id) + await log_activity( + workspace_id, user_id, "db.disconnected", "connection", db_id, {} + ) return {"ok": True} -async def get_schema(user_id, db, refresh): - if not db.connected(user_id): +async def get_schema(workspace_id, db, refresh, db_id=None): + actual_db_id = await ensure_connection(db, workspace_id, db_id) + if not actual_db_id: raise HTTPException(409, "No database connected") - return db.get_schema(user_id, refresh=refresh) + return db.get_schema(workspace_id, db_id=actual_db_id, refresh=refresh) diff --git a/backend/app/controllers/onboard.py b/backend/app/controllers/onboard.py index 3f473bd7..dcd6de81 100644 --- a/backend/app/controllers/onboard.py +++ b/backend/app/controllers/onboard.py @@ -4,13 +4,13 @@ import logging from backend.app.semantic import suggest_from_schema from backend.app.llm import generate_starters -from backend.app.pgdatabase.models import VerifiedQA +from backend.app.models.catalog import VerifiedQA from sqlalchemy.dialects.postgresql import insert log = logging.getLogger(__name__) -async def save(user_id, db, kb, req_data): +async def save(workspace_id, db, kb, req_data): """ Persist onboarding results and initialize the semantic catalog when it is empty. @@ -20,16 +20,16 @@ async def save(user_id, db, kb, req_data): Returns: A dictionary containing `ok` set to `True` and the user's updated trust level. """ - if not db.connected(user_id): + if not db.connected(workspace_id): raise HTTPException(409, "No database connected") - db_id = db.get_db_id(user_id) + db_id = db.get_db_id(workspace_id) if req_data.glossary is not None: await kb.set_glossary( - user_id, db_id, [g.model_dump() for g in req_data.glossary] + workspace_id, db_id, [g.model_dump() for g in req_data.glossary] ) for s in req_data.starters: await kb.add_verified( - user_id, + workspace_id, db_id, s.question, s.sql, @@ -39,25 +39,27 @@ async def save(user_id, db, kb, req_data): # join paths and value-map scaffolding derived straight from the schema — # so linking and the prompt benefit immediately. The user can enrich it # with AI suggestions and edits from Settings afterwards. - if await kb.catalog_is_empty(user_id, db_id): + if await kb.catalog_is_empty(workspace_id, db_id): await kb.set_catalog( - user_id, db_id, suggest_from_schema(db.get_schema(user_id)) + workspace_id, db_id, suggest_from_schema(db.get_schema(workspace_id)) ) - return {"ok": True, "trust": await kb.trust_level(user_id, db_id)} + return {"ok": True, "trust": await kb.trust_level(workspace_id, db_id)} -async def generate_starters_async(user_id, db, kb, providers): - if not db.connected(user_id): +async def generate_starters_async(workspace_id, db, kb, providers): + if not db.connected(workspace_id): raise HTTPException(409, "No database connected") try: - db_id = db.get_db_id(user_id) - schema_text = db.schema_as_text(user_id) - dialect = db.get_dialect(user_id) + db_id = db.get_db_id(workspace_id) + schema_text = db.schema_as_text(workspace_id) + dialect = db.get_dialect(workspace_id) - starters = await generate_starters(providers.get(user_id), schema_text, dialect) + starters = await generate_starters( + providers.get(workspace_id), schema_text, dialect + ) async def _run_starter(s): - res = await run_in_threadpool(db.execute, user_id, s.get("sql", "")) + res = await run_in_threadpool(db.execute, workspace_id, s.get("sql", "")) s["error"] = res.get("error") return s @@ -79,7 +81,7 @@ async def _run_starter(s): .values( [ { - "user_id": user_id, + "workspace_id": workspace_id, "db_id": db_id, "question": s["question"], "sql": s["sql"], @@ -89,7 +91,7 @@ async def _run_starter(s): ] ) .on_conflict_do_nothing( - index_elements=["user_id", "db_id", "question"] + index_elements=["workspace_id", "db_id", "question"] ) ) await session.execute(stmt) diff --git a/backend/app/controllers/query.py b/backend/app/controllers/query.py index c2496a04..4ad218b2 100644 --- a/backend/app/controllers/query.py +++ b/backend/app/controllers/query.py @@ -17,7 +17,14 @@ from fastapi import HTTPException from fastapi.concurrency import run_in_threadpool -from backend.app.llm import LLMError, explain_sql, generate_sql, shortlist_tables +from backend.app.llm import ( + DEFAULT_CHART, + LLMError, + explain_sql, + generate_sql, + parse_chart_spec, + shortlist_tables, +) from backend.app.repair import run_repair_loop, schema_validator from backend.app.schema_link import ( compact_schema, @@ -52,6 +59,7 @@ def _failure_payload(message, tables=None): "sql": "", "restatement": "", "assumptions": [], + "chart": dict(DEFAULT_CHART), "confidence": "low", "confidence_reason": message, "based_on_verified": False, @@ -65,12 +73,81 @@ def _failure_payload(message, tables=None): } -async def run_query(user_id, db, kb, cfg, providers, session_log, req_data): +async def _resolve_db_id(db, workspace_id, db_id=None): + """The database this request runs against, or None if there isn't one. + + Two jobs, both of which every entry point below needs: + + - Restore the connection if this process doesn't hold one. Engines live in + process memory, so a restart or a request served by a different worker + leaves none; `ensure_connection` re-establishes it from the credentials + stored against the workspace rather than failing the request. + - Resolve `X-Db-Id` to a concrete id *once*, so everything downstream — + schema, execution, knowledge, logging — agrees on which database this is. + Reading `db.get_db_id(workspace_id)` separately further down is what let + knowledge and query logs drift onto the workspace default while the SQL + ran against the selected database. + """ + from backend.app.controllers.database import ensure_connection + + return await ensure_connection(db, workspace_id, db_id) + + +def _db_get_schema(db, workspace_id, db_id=None): + if db_id is not None: + try: + return db.get_schema(workspace_id, db_id=db_id) + except TypeError: + pass + try: + return db.get_schema(workspace_id, db_id) + except TypeError: + pass + return db.get_schema(workspace_id) + + +def _db_get_dialect(db, workspace_id, db_id=None): + if db_id is not None: + try: + return db.get_dialect(workspace_id, db_id=db_id) + except TypeError: + pass + try: + return db.get_dialect(workspace_id, db_id) + except TypeError: + pass + return db.get_dialect(workspace_id) + + +def _db_execute(db, workspace_id, sql, db_id=None): + if db_id is not None: + try: + return db.execute(workspace_id, sql, db_id=db_id) + except TypeError: + pass + try: + return db.execute(workspace_id, sql, db_id) + except TypeError: + pass + return db.execute(workspace_id, sql) + + +async def run_query( + workspace_id, + db, + kb, + cfg, + providers, + session_log, + req_data, + db_id=None, + user_id=None, +): """ Generate, validate, and execute SQL for a user's question, with confidence scoring and repair attempts. Parameters: - user_id: Identifier of the user whose database and knowledge base are used. + workspace_id: Workspace scoping unit whose database and knowledge base are used. db: Database connection and schema provider. kb: Knowledge base used for glossary, catalog, and verified-answer retrieval. cfg: Application configuration. @@ -84,7 +161,8 @@ async def run_query(user_id, db, kb, cfg, providers, session_log, req_data): Raises: HTTPException: If no database is connected or the question is empty. """ - if not db.connected(user_id): + db_id = await _resolve_db_id(db, workspace_id, db_id) + if not db_id: raise HTTPException(409, "No database connected") q = req_data.question.strip() if not q: @@ -92,22 +170,21 @@ async def run_query(user_id, db, kb, cfg, providers, session_log, req_data): context = req_data.context # Step 1 — user knowledge: confirmed term meanings + similar verified answers. - db_id = db.get_db_id(user_id) - glossary = await kb.get_glossary(user_id, db_id) - catalog = await kb.get_catalog(user_id, db_id) - retrieved = await kb.retrieve_similar(user_id, db_id, q, k=3) + glossary = await kb.get_glossary(workspace_id, db_id) + catalog = await kb.get_catalog(workspace_id, db_id) + retrieved = await kb.retrieve_similar(workspace_id, db_id, q, k=3) # Step 2 — schema linking: budget for the configured model, then pick tables. budget = model_budget() - full_schema = db.get_schema(user_id) - dialect = db.get_dialect(user_id) + full_schema = _db_get_schema(db, workspace_id, db_id) + dialect = _db_get_dialect(db, workspace_id, db_id) context_tables = ( extract_table_names_from_prev_query(context[-1].sql, dialect) if context else set() ) try: - provider = providers.get(user_id) + provider = providers.get(workspace_id) except LLMError as e: raise HTTPException(503, e.user_message) @@ -158,7 +235,7 @@ async def _generate(feedback): ) async def _execute(sql): - return await run_in_threadpool(db.execute, user_id, sql) + return await run_in_threadpool(_db_execute, db, workspace_id, sql, db_id) def _on_failure(sql, errors): nonlocal linked @@ -203,6 +280,7 @@ def _on_failure(sql, errors): "sql": loop_result["sql"], "restatement": loop_result["restatement"], "assumptions": loop_result.get("assumptions", []), + "chart": parse_chart_spec(loop_result.get("chart")), "confidence": confidence, "confidence_reason": reason, "based_on_verified": based, @@ -221,25 +299,30 @@ def _on_failure(sql, errors): return out -async def explain(user_id, db, providers, req_data): +async def explain(workspace_id, db, providers, req_data, db_id=None): """Translate a SQL query into plain English (trust feature).""" - if not db.connected(user_id): + db_id = await _resolve_db_id(db, workspace_id, db_id) + if not db_id: raise HTTPException(409, "No database connected") sql = (req_data.sql or "").strip() if not sql: raise HTTPException(400, "Empty SQL") try: - return await explain_sql(providers.get(user_id), sql, db.get_dialect(user_id)) + return await explain_sql( + providers.get(workspace_id), + sql, + _db_get_dialect(db, workspace_id, db_id), + ) except LLMError as e: raise HTTPException(502, e.user_message) -async def feedback(user_id, db, kb, session_log, req_data): +async def feedback(workspace_id, db, kb, session_log, req_data, db_id=None): """ Record feedback for a query and update verified knowledge when the feedback is positive. Parameters: - user_id: Identifier of the user submitting the feedback. + workspace_id: Workspace scoping unit submitting the feedback. db: Database connection manager used to identify the connected database. kb: Knowledge base used to store verified queries and retrieve trust information. session_log: Session logger used to record the feedback. @@ -251,31 +334,37 @@ async def feedback(user_id, db, kb, session_log, req_data): Raises: HTTPException: If no database is connected for the user. """ - if not db.connected(user_id): + db_id = await _resolve_db_id(db, workspace_id, db_id) + if not db_id: raise HTTPException(409, "No database connected") session_log.log_feedback(req_data.query_id, req_data.verdict, req_data.reason) + # Verified knowledge belongs to the database the answer was checked against. + # Writing it under the workspace default instead would teach the wrong + # database this SQL is correct, and hand back that database's trust score. if req_data.verdict == "correct": await kb.add_verified( - user_id, - db.get_db_id(user_id), + workspace_id, + db_id, req_data.question, req_data.sql, req_data.restatement, ) - out = {"ok": True, "trust": await kb.trust_level(user_id, db.get_db_id(user_id))} + out = { + "ok": True, + "trust": await kb.trust_level(workspace_id, db_id), + } if req_data.verdict == "correct": out["starters"] = [ - v["question"] - for v in (await kb.get_verified(user_id, db.get_db_id(user_id)))[:6] + v["question"] for v in (await kb.get_verified(workspace_id, db_id))[:6] ] return out -async def verify(user_id, db, kb, req_data): +async def verify(workspace_id, db, kb, req_data, db_id=None): """Mark a question and its SQL explanation as verified. Parameters: - user_id: Identifier of the user verifying the query. + workspace_id: Workspace scoping unit verifying the query. db: Database connection used to determine the active database. kb: Knowledge base used to store the verified query and calculate trust. req_data: Request containing the question, SQL statement, and restatement. @@ -286,24 +375,28 @@ async def verify(user_id, db, kb, req_data): Raises: HTTPException: If no database is connected for the user. """ - if not db.connected(user_id): + db_id = await _resolve_db_id(db, workspace_id, db_id) + if not db_id: raise HTTPException(409, "No database connected") await kb.add_verified( - user_id, - db.get_db_id(user_id), + workspace_id, + db_id, req_data.question, req_data.sql, req_data.restatement, ) - return {"ok": True, "trust": await kb.trust_level(user_id, db.get_db_id(user_id))} + return { + "ok": True, + "trust": await kb.trust_level(workspace_id, db_id), + } -async def execute(user_id, db, req_data): +async def execute(workspace_id, db, req_data, db_id=None): """ Execute the requested SQL statement against the user's connected database. Parameters: - user_id: Identifier of the user whose database connection should be used. + workspace_id: Workspace scoping unit whose database connection should be used. db: Database service used to execute the statement. req_data: Request containing the SQL statement. @@ -313,9 +406,10 @@ async def execute(user_id, db, req_data): Raises: HTTPException: If no database is connected or the SQL execution returns an error. """ - if not db.connected(user_id): + db_id = await _resolve_db_id(db, workspace_id, db_id) + if not db_id: raise HTTPException(409, "No database connected") - res = await run_in_threadpool(db.execute, user_id, req_data.sql) + res = await run_in_threadpool(db.execute, workspace_id, req_data.sql, db_id) if "error" in res: raise HTTPException(400, res["error"]) return res @@ -399,12 +493,22 @@ def _build_checks(verdict, schema=None): # ── Streaming query controller ──────────────────────────────────── -async def run_query_stream(user_id, db, kb, cfg, providers, session_log, req_data): +async def run_query_stream( + workspace_id, + db, + kb, + cfg, + providers, + session_log, + req_data, + db_id=None, + user_id=None, +): """ Stream the SQL generation, validation, execution, confidence, and result events for a question. Parameters: - user_id: Identifier of the requesting user. + workspace_id: Workspace scoping unit. db: Database connection and schema provider. kb: Knowledge base used for glossary, catalog, and verified-answer retrieval. cfg: Application configuration. @@ -416,7 +520,8 @@ async def run_query_stream(user_id, db, kb, cfg, providers, session_log, req_dat dict: Progress, error, or final-result event. The final result includes generated SQL, execution data, confidence information, and the recorded query identifier. """ - if not db.connected(user_id): + db_id = await _resolve_db_id(db, workspace_id, db_id) + if not db_id: yield {"kind": "error", "message": "No database connected"} return q = req_data.question.strip() @@ -426,14 +531,18 @@ async def run_query_stream(user_id, db, kb, cfg, providers, session_log, req_dat context = req_data.context query_start = time.monotonic() - dbid = db.get_db_id(user_id) - glossary = await kb.get_glossary(user_id, dbid) - catalog = await kb.get_catalog(user_id, dbid) - retrieved = await kb.retrieve_similar(user_id, dbid, q, k=3) + # Knowledge is scoped to the database being queried. Reading it from the + # workspace default while the SQL runs against the selected database fed + # the model another database's glossary, catalog and verified examples. + glossary = await kb.get_glossary(workspace_id, db_id) + catalog = await kb.get_catalog(workspace_id, db_id) + retrieved = await kb.retrieve_similar(workspace_id, db_id, q, k=3) budget = model_budget() - full_schema = db.get_schema(user_id) + full_schema = _db_get_schema(db, workspace_id, db_id) context_tables = ( - extract_table_names_from_prev_query(context[-1].sql, db.get_dialect(user_id)) + extract_table_names_from_prev_query( + context[-1].sql, _db_get_dialect(db, workspace_id, db_id) + ) if context else set() ) @@ -450,7 +559,7 @@ async def run_query_stream(user_id, db, kb, cfg, providers, session_log, req_dat # Only the catalog entries for the linked tables go into the prompt. prompt_catalog = filter_catalog(catalog, tables) try: - provider_obj = providers.get(user_id) + provider_obj = providers.get(workspace_id) except LLMError as e: # Surface the configuration problem (e.g. missing API key) instead of # letting it escape the generator and get masked as an internal error. @@ -469,6 +578,7 @@ async def run_query_stream(user_id, db, kb, cfg, providers, session_log, req_dat max_iterations = _MAX_ATTEMPTS feedback = "" last_restatement = "" + last_chart = dict(DEFAULT_CHART) exec_result = None exec_elapsed = 0 @@ -477,7 +587,7 @@ async def run_query_stream(user_id, db, kb, cfg, providers, session_log, req_dat provider_obj, q, schema_text, - db.get_dialect(user_id), + _db_get_dialect(db, workspace_id, db_id), glossary, retrieved, budget["max_examples"], @@ -518,14 +628,18 @@ async def run_query_stream(user_id, db, kb, cfg, providers, session_log, req_dat sql = (gen_result.get("sql") or "").strip() restatement = (gen_result.get("restatement") or "").strip() last_restatement = restatement + last_chart = parse_chart_spec(gen_result.get("chart")) if not sql: yield {"kind": "error", "message": "Model returned empty SQL"} return yield {"kind": "sql", "attempt": attempt, "sql": sql} + yield {"kind": "chart", "attempt": attempt, "chart": last_chart} - verdict = validate_sql(sql, full_schema, db.get_dialect(user_id)) + verdict = validate_sql( + sql, full_schema, _db_get_dialect(db, workspace_id, db_id) + ) checks = _build_checks(verdict, full_schema) passed = verdict.get("ok", False) @@ -567,7 +681,9 @@ async def run_query_stream(user_id, db, kb, cfg, providers, session_log, req_dat # a query that only breaks at execution still gets another attempt. exec_start = time.monotonic() try: - exec_result = await run_in_threadpool(db.execute, user_id, sql) + exec_result = await run_in_threadpool( + _db_execute, db, workspace_id, sql, db_id + ) except Exception: log.warning("Query execution failed during streaming", exc_info=True) exec_result = {"error": "The query could not be run against the database."} @@ -627,6 +743,7 @@ async def run_query_stream(user_id, db, kb, cfg, providers, session_log, req_dat "answered": True, "sql": sql, "restatement": last_restatement, + "chart": last_chart, "confidence": confidence, "confidence_reason": reason, "based_on_verified": based, @@ -637,7 +754,7 @@ async def run_query_stream(user_id, db, kb, cfg, providers, session_log, req_dat } if "error" in exec_result: out["execution_error"] = exec_result["error"] - out["query_id"] = session_log.log_query(db.get_db_id(user_id), q, out) + out["query_id"] = session_log.log_query(db_id, q, out) # Persist to query history so the dashboard and history reflect streamed # queries too (the /api/query/stream route can't save after the response @@ -645,7 +762,7 @@ async def run_query_stream(user_id, db, kb, cfg, providers, session_log, req_dat # Only link the turn to a conversation the caller actually owns conversation_id = req_data.conversation_id if conversation_id and not await mdb.conversation_owned_by( - user_id, conversation_id + workspace_id, user_id, conversation_id ): conversation_id = None @@ -659,6 +776,7 @@ async def run_query_stream(user_id, db, kb, cfg, providers, session_log, req_dat ) try: await mdb.save_query( + workspace_id=workspace_id, user_id=user_id, question=q, sql=out["sql"], @@ -666,9 +784,10 @@ async def run_query_stream(user_id, db, kb, cfg, providers, session_log, req_dat confidence=conf_str, conversation_id=conversation_id, restatement=out.get("restatement", ""), + chart=out.get("chart"), ) if conversation_id: - await mdb.touch_conversation(conversation_id) + await mdb.touch_conversation(conversation_id, user_id) except Exception: log.warning("Failed to persist streamed query history", exc_info=True) diff --git a/backend/app/controllers/system.py b/backend/app/controllers/system.py index 3a0034ef..d7b43ba4 100644 --- a/backend/app/controllers/system.py +++ b/backend/app/controllers/system.py @@ -4,6 +4,7 @@ from fastapi import HTTPException from backend.app import config as cfgmod +import backend.app.controllers.database as dbctrl from backend.app.secrets import ( get_jwt_secret, get_supabase_url, @@ -14,7 +15,7 @@ log = logging.getLogger(__name__) -async def get_state(user_id, db, cfg, kb): +async def get_state(user_id, workspace_id, db_id, db, cfg, kb): """ Assemble the user's connection, configuration, onboarding, and knowledge state. @@ -29,25 +30,37 @@ async def get_state(user_id, db, cfg, kb): config = cfgmod.public_config(cfg) config.pop("last_db_url", None) user = await mdb.get_user_by_id(user_id) + # Restore the workspace's database from its stored credentials if this + # process doesn't hold a live engine for it — otherwise a restart reads to + # the user as their database having disconnected itself. + actual_db_id = await dbctrl.ensure_connection(db, workspace_id, db_id) s = { - "connected": db.connected(user_id), + "connected": bool(actual_db_id), "config": config, "openrouter_ready": bool(os.environ.get("OPENROUTER_API_KEY")), "tour_completed": user.get("tour_completed", False) if user else False, } - if db.connected(user_id): - dbid = db.get_db_id(user_id) + if actual_db_id: + try: + conn = await mdb.get_recent_connection_by_db_id(workspace_id, actual_db_id) + except RuntimeError: + # Only the alias is needed here; an unreadable stored URL must not + # take down the whole state response. + log.warning("Could not read stored connection for db_id=%s", actual_db_id) + conn = None s["database"] = { - "url": db.get_info(user_id)["url"], - "dialect": db.get_dialect(user_id), - "db_id": db.get_info(user_id)["db_id"], - "tables": db.get_info(user_id)["tables"], - "has_knowledge": (await kb.count_verified(user_id, dbid)) > 0, + "url": db.get_info(workspace_id, actual_db_id)["url"], + "dialect": db.get_dialect(workspace_id, actual_db_id), + "db_id": actual_db_id, + "tables": db.get_info(workspace_id, actual_db_id)["tables"], + "has_knowledge": (await kb.count_verified(workspace_id, actual_db_id)) > 0, + "alias_name": conn.get("alias_name") if conn else None, } - s["trust"] = await kb.trust_level(user_id, dbid) - s["glossary"] = await kb.get_glossary(user_id, dbid) + s["trust"] = await kb.trust_level(workspace_id, actual_db_id) + s["glossary"] = await kb.get_glossary(workspace_id, actual_db_id) s["starters"] = [ - v["question"] for v in (await kb.get_verified(user_id, dbid))[:6] + v["question"] + for v in (await kb.get_verified(workspace_id, actual_db_id))[:6] ] return s diff --git a/backend/app/controllers/workspaces.py b/backend/app/controllers/workspaces.py new file mode 100644 index 00000000..b8763625 --- /dev/null +++ b/backend/app/controllers/workspaces.py @@ -0,0 +1,883 @@ +import logging +import re +import secrets +from datetime import datetime, timedelta, timezone +from fastapi import HTTPException +from pydantic import EmailStr, TypeAdapter, ValidationError +import asyncio +from sqlalchemy import select, func +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import aliased +from backend.app.services.email import send_workspace_invite_email +from backend.app.pgdatabase.engine import async_session +from backend.app.models.workspace import Workspace, WorkspaceMember, WorkspaceInvite +from backend.app.models.workspace_settings import WorkspaceSettings +from backend.app.permissions import resolve_role_permissions +from backend.app.models.orm_user import User +from backend.app.pgdatabase.serialization import _to_uuid +from backend.app.controllers.activity import log_activity + + +log = logging.getLogger(__name__) + +INVITE_EXPIRY_DAYS = 7 + +_EMAIL_ADAPTER = TypeAdapter(EmailStr) + + +def slugify(text: str) -> str: + text = text.lower() + text = re.sub(r"[^a-z0-9]+", "-", text) + return text.strip("-") + + +# Crockford base32: no I, L, O or U, so a code survives being read aloud or +# copied by hand without the 1/I and 0/O confusions. +_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + + +def generate_invite_code() -> str: + """A short, human-readable invite code in the form BOLO-XXXX-XXXX.""" + body = "".join(secrets.choice(_CODE_ALPHABET) for _ in range(8)) + return f"BOLO-{body[:4]}-{body[4:]}" + + +def normalize_invite_code(token: str) -> str: + """Canonicalise a pasted code so casing, spaces and stray hyphens still match. + + Only applied to codes in our own format — legacy UUID tokens are left as + typed, since they are lowercase and hyphen-significant. + """ + if not token: + return "" + stripped = token.strip() + compact = re.sub(r"[\s-]+", "", stripped).upper() + if not compact.startswith("BOLO") or len(compact) != 12: + return stripped + return f"BOLO-{compact[4:8]}-{compact[8:]}" + + +async def list_workspaces(user_id: str): + uid = _to_uuid(user_id) + async with async_session() as session: + # `member_count` powers the sidebar workspace pills. A correlated + # scalar subquery keeps this one round trip rather than one COUNT per + # workspace. + member_count = ( + select(func.count()) + .select_from(WorkspaceMember) + .where(WorkspaceMember.workspace_id == Workspace.id) + .scalar_subquery() + ) + membership = aliased(WorkspaceMember) + result = await session.execute( + select(Workspace, membership.role, member_count) + .join(membership, Workspace.id == membership.workspace_id) + .where(membership.user_id == uid) + .order_by(Workspace.created_at.desc()) + ) + rows = result.all() + return [ + { + "id": str(r[0].id), + "name": r[0].name, + "slug": r[0].slug, + "role": r[1], + "member_count": r[2], + "created_at": r[0].created_at, + } + for r in rows + ] + + +async def create_workspace(user_id: str, name: str): + uid = _to_uuid(user_id) + base_slug = slugify(name) or "workspace" + + max_attempts = 10 + slug = base_slug + + for attempt in range(max_attempts): + async with async_session() as session: + try: + if attempt > 0: + slug = f"{base_slug}-{attempt}" + + exists = await session.execute( + select(Workspace).where(Workspace.slug == slug) + ) + if exists.scalar_one_or_none(): + continue + + workspace = Workspace(name=name, slug=slug, created_by=uid) + session.add(workspace) + await session.flush() + + member = WorkspaceMember( + workspace_id=workspace.id, user_id=uid, role="owner" + ) + session.add(member) + await session.commit() + await log_activity( + str(workspace.id), + user_id, + "workspace.created", + "workspace", + str(workspace.id), + {"name": name}, + ) + return { + "id": str(workspace.id), + "name": workspace.name, + "slug": workspace.slug, + "role": "owner", + } + except IntegrityError: + await session.rollback() + continue + + raise HTTPException(500, "Could not generate a unique workspace slug") + + +async def get_workspace(workspace_id: str, user_id: str): + wid = _to_uuid(workspace_id) + uid = _to_uuid(user_id) + async with async_session() as session: + result = await session.execute( + select(Workspace, WorkspaceMember.role) + .join(WorkspaceMember, Workspace.id == WorkspaceMember.workspace_id) + .where(Workspace.id == wid, WorkspaceMember.user_id == uid) + ) + row = result.first() + if not row: + raise HTTPException(404, "Workspace not found") + return { + "id": str(row[0].id), + "name": row[0].name, + "slug": row[0].slug, + "role": row[1], + "created_at": row[0].created_at, + } + + +async def update_workspace( + workspace_id: str, name: str | None, actor_id: str | None = None +): + wid = _to_uuid(workspace_id) + async with async_session() as session: + try: + from sqlalchemy import update + + stmt = update(Workspace).where(Workspace.id == wid) + values = {} + if name is not None: + values["name"] = name.strip() + if values: + stmt = stmt.values(**values) + result = await session.execute(stmt) + await session.commit() + + await log_activity( + workspace_id, + actor_id, + "workspace.updated", + "workspace", + workspace_id, + values, + ) + return {"ok": result.rowcount > 0, "name": values.get("name")} + return {"ok": True} + except Exception: + await session.rollback() + raise + + +async def delete_workspace(workspace_id: str, actor_id: str | None = None): + """Delete a workspace and everything scoped to it. + + Every workspace-scoped table declares ``ondelete="CASCADE"`` against + ``workspaces.id``, so removing this one row takes members, invites, activity, + catalog, conversations, dashboards and saved queries with it. That includes + the workspace's own activity log, so the deletion is recorded to the + application log rather than to an audit row that is about to vanish. + """ + wid = _to_uuid(workspace_id) + async with async_session() as session: + workspace = await session.get(Workspace, wid) + if not workspace: + raise HTTPException(404, "Workspace not found") + name = workspace.name + await session.delete(workspace) + await session.commit() + + log.info("workspace.deleted id=%s name=%r actor=%s", workspace_id, name, actor_id) + return {"ok": True} + + +async def leave_workspace(workspace_id: str, user_id: str): + """Remove the calling user from a workspace. + + A sole owner cannot leave — they must transfer ownership or delete the + workspace, otherwise it would be left with nobody able to administer it. + """ + wid = _to_uuid(workspace_id) + uid = _to_uuid(user_id) + async with async_session() as session: + result = await session.execute( + select(WorkspaceMember).where( + WorkspaceMember.workspace_id == wid, WorkspaceMember.user_id == uid + ) + ) + member = result.scalar_one_or_none() + if not member: + raise HTTPException(404, "Member not found") + + if member.role == "owner": + owners_count = await session.scalar( + select(func.count()) + .select_from(WorkspaceMember) + .where( + WorkspaceMember.workspace_id == wid, + WorkspaceMember.role == "owner", + ) + ) + if owners_count <= 1: + raise HTTPException( + 400, + "You are the sole owner — transfer ownership or delete the workspace first", + ) + + await session.delete(member) + await session.commit() + + await log_activity(workspace_id, user_id, "member.left", "member", user_id, {}) + return {"ok": True} + + +async def list_members(workspace_id: str): + wid = _to_uuid(workspace_id) + async with async_session() as session: + result = await session.execute( + select(WorkspaceMember, User.email) + .join(User, WorkspaceMember.user_id == User.id) + .where(WorkspaceMember.workspace_id == wid) + ) + rows = result.all() + return [ + { + "user_id": str(r[0].user_id), + "email": r[1], + "role": r[0].role, + "joined_at": r[0].joined_at, + "created_at": r[0].joined_at, + } + for r in rows + ] + + +async def invite_user(workspace_id: str, email: str, role: str | None, inviter_id: str): + wid = _to_uuid(workspace_id) + uid = _to_uuid(inviter_id) + + async with async_session() as session: + settings_res = await session.execute( + select(WorkspaceSettings).where(WorkspaceSettings.workspace_id == wid) + ) + settings = settings_res.scalar_one_or_none() + default_role = getattr(settings, "default_invite_role", "member") + expiry_days = getattr(settings, "invite_expiry_days", INVITE_EXPIRY_DAYS) + + target_role = role if role else default_role + if target_role not in ["admin", "member"]: + raise HTTPException(400, "Invalid role for invite") + + # Check if user is already a member + user_exists = await session.execute(select(User).where(User.email == email)) + user = user_exists.scalar_one_or_none() + if user: + member = await session.execute( + select(WorkspaceMember).where( + WorkspaceMember.workspace_id == wid, + WorkspaceMember.user_id == user.id, + ) + ) + if member.scalar_one_or_none(): + raise HTTPException(400, "User is already a member") + + token = generate_invite_code() + expires = datetime.now(timezone.utc) + timedelta(days=expiry_days) + + # Query existing invite for this workspace + email + existing = await session.execute( + select(WorkspaceInvite).where( + WorkspaceInvite.workspace_id == wid, + WorkspaceInvite.email == email, + ) + ) + invite = existing.scalar_one_or_none() + + if invite: + invite.role = target_role + invite.token = token + invite.invited_by = uid + invite.expires_at = expires + invite.accepted_at = None + else: + invite = WorkspaceInvite( + workspace_id=wid, + email=email, + role=target_role, + token=token, + invited_by=uid, + expires_at=expires, + ) + session.add(invite) + + try: + await session.commit() + + # Fetch workspace name for email + ws = await session.get(Workspace, wid) + if ws: + await send_workspace_invite_email(email, ws.name, token) + + await log_activity( + workspace_id, + inviter_id, + "member.invited", + "member", + str(invite.id), + {"email": email, "role": target_role}, + ) + return { + "id": str(invite.id), + "email": invite.email, + "role": invite.role, + "token": invite.token, + "expires_at": invite.expires_at, + } + except IntegrityError: + await session.rollback() + raise HTTPException(400, "Could not process invite") + + +async def update_member_role( + workspace_id: str, + target_user_id: str, + role: str, + actor_id: str | None = None, + actor_role: str = "admin", +): + wid = _to_uuid(workspace_id) + tuid = _to_uuid(target_user_id) + if role not in ["admin", "member"]: + raise HTTPException(400, "Invalid role") + + role_rank = {"member": 1, "admin": 2, "owner": 3} + actor_rank = role_rank.get(actor_role, 0) + if actor_rank < role_rank["admin"]: + raise HTTPException(403, "Insufficient permissions") + + async with async_session() as session: + result = await session.execute( + select(WorkspaceMember).where( + WorkspaceMember.workspace_id == wid, WorkspaceMember.user_id == tuid + ) + ) + member = result.scalar_one_or_none() + if not member: + raise HTTPException(404, "Member not found") + + target_rank = role_rank.get(member.role, 0) + # Callers may only modify members strictly below their own rank. + if target_rank >= actor_rank: + raise HTTPException( + 403, "Cannot change the role of a member at or above your own level" + ) + if role_rank.get(role, 0) >= actor_rank: + raise HTTPException(403, "Cannot assign a role at or above your own level") + + if member.role == "owner" and role != "owner": + raise HTTPException(400, "Cannot change role of the sole workspace owner") + + member.role = role + await session.commit() + await log_activity( + workspace_id, + actor_id, + "member.role_updated", + "member", + target_user_id, + {"new_role": role}, + ) + return {"ok": True} + + +async def transfer_ownership( + workspace_id: str, current_owner_id: str, target_user_id: str +): + """Hand the owner role to another member, demoting the caller to admin. + + Both role changes happen in one transaction so the workspace is never left + with two owners or none. + """ + wid = _to_uuid(workspace_id) + if current_owner_id == target_user_id: + raise HTTPException(400, "You already own this workspace") + try: + tuid = _to_uuid(target_user_id) + except (ValueError, AttributeError, TypeError): + raise HTTPException(404, "Member not found") + cuid = _to_uuid(current_owner_id) + + async with async_session() as session: + result = await session.execute( + select(WorkspaceMember).where( + WorkspaceMember.workspace_id == wid, + WorkspaceMember.user_id.in_([tuid, cuid]), + ) + ) + by_user = {m.user_id: m for m in result.scalars().all()} + target = by_user.get(tuid) + actor = by_user.get(cuid) + if not target: + raise HTTPException(404, "Member not found") + if not actor or actor.role != "owner": + raise HTTPException(403, "Only the owner can transfer ownership") + + target.role = "owner" + actor.role = "admin" + await session.commit() + + await log_activity( + workspace_id, + current_owner_id, + "member.ownership_transferred", + "member", + target_user_id, + {"previous_owner": current_owner_id}, + ) + return {"ok": True, "new_owner_id": target_user_id} + + +def _normalize_email(raw: str) -> str | None: + """Return a validated, lower-cased address, or None if it isn't one. + + Bulk invites report bad addresses per row instead of rejecting the whole + batch, so validation happens here rather than in the request model. + """ + if not isinstance(raw, str): + return None + candidate = raw.strip().lower() + if not candidate: + return None + try: + return str(_EMAIL_ADAPTER.validate_python(candidate)).lower() + except ValidationError: + return None + + +async def bulk_invite( + workspace_id: str, emails: list[str], role: str, inviter_id: str +) -> dict: + """Invite many people at once, reporting the outcome for each address. + + One bad or duplicate address must not sink the rest of the batch, so each + invite is attempted independently and its status recorded. + """ + if role not in ["admin", "member"]: + raise HTTPException(400, "Invalid role for invite") + + results: list[dict] = [{}] * len(emails) + valid_tasks = [] + valid_indices = [] + seen: set[str] = set() + + sem = asyncio.Semaphore(10) + + async def _invite_worker(email: str): + async with sem: + try: + await invite_user(workspace_id, email, role, inviter_id) + return {"email": email, "status": "invited"} + except HTTPException as e: + detail = str(e.detail or "") + status = "already_member" if "already a member" in detail else "failed" + return {"email": email, "status": status, "detail": detail} + + for i, raw in enumerate(emails): + email = _normalize_email(raw) + if not email: + results[i] = {"email": (raw or "").strip(), "status": "invalid"} + continue + if email in seen: + results[i] = {"email": email, "status": "duplicate"} + continue + seen.add(email) + valid_indices.append(i) + valid_tasks.append(_invite_worker(email)) + + if valid_tasks: + worker_results = await asyncio.gather(*valid_tasks) + for idx, res in zip(valid_indices, worker_results): + results[idx] = res + + invited = sum(1 for r in results if r["status"] == "invited") + return {"invited": invited, "total": len(results), "results": results} + + +async def remove_member( + workspace_id: str, + target_user_id: str, + actor_id: str | None = None, + actor_role: str = "admin", +): + wid = _to_uuid(workspace_id) + tuid = _to_uuid(target_user_id) + actor_uuid = _to_uuid(actor_id) if actor_id else None + + role_rank = {"member": 1, "admin": 2, "owner": 3} + actor_rank = role_rank.get(actor_role, 0) + + async with async_session() as session: + result = await session.execute( + select(WorkspaceMember).where( + WorkspaceMember.workspace_id == wid, WorkspaceMember.user_id == tuid + ) + ) + member = result.scalar_one_or_none() + if not member: + raise HTTPException(404, "Member not found") + + target_rank = role_rank.get(member.role, 0) + + if actor_uuid != tuid: + if target_rank >= actor_rank: + raise HTTPException( + 403, "Cannot remove a member at or above your own level" + ) + if actor_rank < role_rank["admin"]: + raise HTTPException(403, "Insufficient permissions") + + if member.role == "owner": + owners_count = await session.scalar( + select(func.count()) + .select_from(WorkspaceMember) + .where( + WorkspaceMember.workspace_id == wid, + WorkspaceMember.role == "owner", + ) + ) + if owners_count <= 1: + raise HTTPException(400, "Cannot remove the sole owner of a workspace") + + await session.delete(member) + await session.commit() + await log_activity( + workspace_id, actor_id, "member.removed", "member", target_user_id, {} + ) + return {"ok": True} + + +async def get_invites(user_email: str): + async with async_session() as session: + now = datetime.now(timezone.utc) + result = await session.execute( + select(WorkspaceInvite, Workspace.name) + .join(Workspace, WorkspaceInvite.workspace_id == Workspace.id) + .where( + WorkspaceInvite.email == user_email, + WorkspaceInvite.accepted_at.is_(None), + WorkspaceInvite.expires_at > now, + ) + ) + rows = result.all() + return [ + { + "id": str(r[0].id), + "workspace_id": str(r[0].workspace_id), + "workspace_name": r[1], + "role": r[0].role, + "token": r[0].token, + "expires_at": r[0].expires_at, + } + for r in rows + ] + + +async def list_pending_invites(workspace_id: str): + """Invites for this workspace that are neither accepted nor expired.""" + wid = _to_uuid(workspace_id) + inviter = aliased(User) + async with async_session() as session: + now = datetime.now(timezone.utc) + result = await session.execute( + select(WorkspaceInvite, inviter.email) + .outerjoin(inviter, WorkspaceInvite.invited_by == inviter.id) + .where( + WorkspaceInvite.workspace_id == wid, + WorkspaceInvite.accepted_at.is_(None), + WorkspaceInvite.expires_at > now, + ) + .order_by(WorkspaceInvite.expires_at.desc()) + ) + return [ + { + "id": str(r[0].id), + "email": r[0].email, + "role": r[0].role, + "invited_by": r[1], + "expires_at": r[0].expires_at, + } + for r in result.all() + ] + + +async def _get_invite(session, wid, invite_id: str) -> WorkspaceInvite: + try: + iid = _to_uuid(invite_id) + except (ValueError, AttributeError, TypeError): + raise HTTPException(404, "Invite not found") + result = await session.execute( + select(WorkspaceInvite).where( + WorkspaceInvite.id == iid, + WorkspaceInvite.workspace_id == wid, + ) + ) + invite = result.scalar_one_or_none() + if not invite: + raise HTTPException(404, "Invite not found") + return invite + + +async def rescind_invite( + workspace_id: str, invite_id: str, actor_id: str | None = None +): + wid = _to_uuid(workspace_id) + async with async_session() as session: + invite = await _get_invite(session, wid, invite_id) + if invite.accepted_at: + raise HTTPException(400, "Invite has already been accepted") + email = invite.email + await session.delete(invite) + await session.commit() + + await log_activity( + workspace_id, + actor_id, + "member.invite_revoked", + "member", + invite_id, + {"email": email}, + ) + return {"ok": True} + + +async def resend_invite(workspace_id: str, invite_id: str, actor_id: str | None = None): + """Re-send an invite email and push its expiry out. + + The existing code is kept so any already-delivered email still works; only an + expired invite gets a fresh token. + """ + wid = _to_uuid(workspace_id) + async with async_session() as session: + invite = await _get_invite(session, wid, invite_id) + if invite.accepted_at: + raise HTTPException(400, "Invite has already been accepted") + + settings_res = await session.execute( + select(WorkspaceSettings).where(WorkspaceSettings.workspace_id == wid) + ) + settings = settings_res.scalar_one_or_none() + expiry_days = getattr(settings, "invite_expiry_days", INVITE_EXPIRY_DAYS) + + now = datetime.now(timezone.utc) + if invite.expires_at < now: + invite.token = generate_invite_code() + invite.expires_at = now + timedelta(days=expiry_days) + + workspace = await session.get(Workspace, wid) + workspace_name = workspace.name if workspace else None + await session.commit() + + email, token, expires_at = invite.email, invite.token, invite.expires_at + + sent = False + if workspace_name: + sent = await send_workspace_invite_email(email, workspace_name, token) + + await log_activity( + workspace_id, + actor_id, + "member.invite_resent", + "member", + invite_id, + {"email": email}, + ) + return {"ok": True, "email_sent": sent, "expires_at": expires_at} + + +async def accept_invite(token: str, user_id: str, user_email: str): + uid = _to_uuid(user_id) + token = normalize_invite_code(token) + async with async_session() as session: + result = await session.execute( + select(WorkspaceInvite).where( + WorkspaceInvite.token == token, WorkspaceInvite.email == user_email + ) + ) + invite = result.scalar_one_or_none() + if not invite: + raise HTTPException(404, "Invite not found or invalid email") + if invite.accepted_at: + raise HTTPException(400, "Invite already accepted") + if invite.expires_at < datetime.now(invite.expires_at.tzinfo): + raise HTTPException(400, "Invite expired") + + try: + member = WorkspaceMember( + workspace_id=invite.workspace_id, user_id=uid, role=invite.role + ) + invite.accepted_at = datetime.now(invite.expires_at.tzinfo) + session.add(member) + await session.commit() + await log_activity( + str(invite.workspace_id), + user_id, + "member.joined", + "member", + user_id, + {"role": invite.role}, + ) + return {"ok": True, "workspace_id": str(invite.workspace_id)} + except IntegrityError: + await session.rollback() + raise HTTPException(400, "User is already a member of this workspace") + + +def _permission_matrix(overrides): + """The full role → permission → allowed map. `None` gives the defaults.""" + return { + role: resolve_role_permissions(role, overrides) + for role in ("owner", "admin", "member") + } + + +async def get_workspace_settings(workspace_id: str): + wid = _to_uuid(workspace_id) + async with async_session() as session: + result = await session.execute( + select(WorkspaceSettings).where(WorkspaceSettings.workspace_id == wid) + ) + settings = result.scalar_one_or_none() + if not settings: + settings = WorkspaceSettings(workspace_id=wid) + session.add(settings) + await session.commit() + await session.refresh(settings) + + role_perms = settings.role_permissions or {} + return { + "workspace_id": str(settings.workspace_id), + "default_invite_role": settings.default_invite_role, + "invite_expiry_days": settings.invite_expiry_days, + "activity_retention_days": settings.activity_retention_days, + "role_permissions": role_perms, + "resolved_matrix": _permission_matrix(role_perms), + # What the matrix would be with nothing customised. The settings UI + # needs this to tell "this is the default" apart from "someone chose + # this" — without it the UI has to hardcode a second copy of the + # defaults, which silently goes stale when the registry changes. + "default_matrix": _permission_matrix(None), + } + + +async def update_workspace_settings( + workspace_id: str, + update_data: dict, + actor_id: str | None = None, +): + wid = _to_uuid(workspace_id) + + # Exclude any owner overrides + role_perms = update_data.get("role_permissions") + if isinstance(role_perms, dict): + role_perms = dict(role_perms) + role_perms.pop("owner", None) + + async with async_session() as session: + result = await session.execute( + select(WorkspaceSettings).where(WorkspaceSettings.workspace_id == wid) + ) + settings = result.scalar_one_or_none() + if not settings: + settings = WorkspaceSettings(workspace_id=wid) + session.add(settings) + + if ( + "default_invite_role" in update_data + and update_data["default_invite_role"] is not None + ): + settings.default_invite_role = update_data["default_invite_role"] + if ( + "invite_expiry_days" in update_data + and update_data["invite_expiry_days"] is not None + ): + settings.invite_expiry_days = update_data["invite_expiry_days"] + if ( + "activity_retention_days" in update_data + and update_data["activity_retention_days"] is not None + ): + settings.activity_retention_days = update_data["activity_retention_days"] + if role_perms is not None: + if isinstance(role_perms, dict): + if update_data.get("role_permissions") == {}: + settings.role_permissions = {} + else: + current_perms = dict(settings.role_permissions or {}) + current_perms.pop("owner", None) + merged = {} + for role_name, perms in current_perms.items(): + if role_name != "owner": + merged[role_name] = ( + dict(perms) if isinstance(perms, dict) else perms + ) + for role_name, perms in role_perms.items(): + if role_name == "owner": + continue + if isinstance(perms, dict): + if role_name in merged and isinstance( + merged[role_name], dict + ): + merged[role_name] = {**merged[role_name], **perms} + else: + merged[role_name] = dict(perms) + else: + merged[role_name] = perms + merged.pop("owner", None) + settings.role_permissions = merged + else: + settings.role_permissions = role_perms + + await session.commit() + await session.refresh(settings) + + await log_activity( + workspace_id, + actor_id, + "workspace.settings_updated", + "workspace", + workspace_id, + update_data, + ) + + curr_perms = settings.role_permissions or {} + return { + "workspace_id": str(settings.workspace_id), + "default_invite_role": settings.default_invite_role, + "invite_expiry_days": settings.invite_expiry_days, + "activity_retention_days": settings.activity_retention_days, + "role_permissions": curr_perms, + "resolved_matrix": _permission_matrix(curr_perms), + "default_matrix": _permission_matrix(None), + } diff --git a/backend/app/database.py b/backend/app/database.py index 12bc31f7..c2c0f217 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -120,11 +120,22 @@ def db_id_for(url): return hashlib.sha256(sanitize_url(url).encode()).hexdigest()[:16] +class _ConnectionsDict(dict): + def __getitem__(self, key): + if key in self: + return super().__getitem__(key) + if isinstance(key, str): + for k, v in self.items(): + if k == key or (isinstance(k, tuple) and k[0] == key): + return v + raise KeyError(key) + + class DatabaseManager: def __init__( self, readonly=True, sample_rows=3, max_rows=500, statement_timeout=30 ): - self._connections = {} + self._connections = _ConnectionsDict() self.readonly = readonly self.sample_rows = sample_rows self.max_rows = max_rows @@ -132,26 +143,39 @@ def __init__( # it. Enforced server-side per connection (see _apply_statement_timeout). self.statement_timeout = statement_timeout - def connected(self, user_id): - return user_id in self._connections + def connected(self, workspace_id, db_id=None): + if db_id: + return (workspace_id, db_id) in self._connections + return any(k[0] == workspace_id for k in self._connections.keys()) - def _get(self, user_id): - try: - return self._connections[user_id] - except KeyError: - raise HTTPException(409, "No Database Connected") + def _get(self, workspace_id, db_id=None): + if db_id: + try: + return self._connections[(workspace_id, db_id)] + except KeyError: + raise HTTPException(409, "No Database Connected") + + # Fallback to the first connected database for this workspace + for k, v in self._connections.items(): + if k[0] == workspace_id: + return v + raise HTTPException(409, "No Database Connected") - def disconnect(self, user_id): + def disconnect(self, workspace_id, db_id=None): """Reset all connection state so the server accepts a new connect call.""" - if user_id not in self._connections: - return - try: - self._connections[user_id]["engine"].dispose() # release pooled connections - except Exception as e: - logger.warning("Error disposing engine on disconnect: %s", e) - del self._connections[user_id] + keys_to_delete = [] + for k in self._connections.keys(): + if k[0] == workspace_id and (db_id is None or k[1] == db_id): + keys_to_delete.append(k) + + for k in keys_to_delete: + try: + self._connections[k]["engine"].dispose() + except Exception as e: + logger.warning("Error disposing engine on disconnect: %s", e) + del self._connections[k] - def connect(self, user_id, url): + def connect(self, workspace_id, url): import os if os.environ.get("RUNNING_IN_DOCKER") == "true": @@ -170,11 +194,12 @@ def connect(self, user_id, url): with engine.connect() as c: c.execute(text("SELECT 1")) tables = len(inspect(engine).get_table_names()) - old_connection = self._connections.get(user_id) - self._connections[user_id] = { + db_id = db_id_for(url) + old_connection = self._connections.get((workspace_id, db_id)) + self._connections[(workspace_id, db_id)] = { "engine": engine, "url": url, - "db_id": db_id_for(url), + "db_id": db_id, "dialect": url.split(":")[0].split("+")[0], "_schema_cache": None, "_table_count": tables, @@ -183,22 +208,22 @@ def connect(self, user_id, url): old_connection["engine"].dispose() return { "ok": True, - "dialect": self._connections[user_id]["dialect"], + "dialect": self._connections[(workspace_id, db_id)]["dialect"], "tables": tables, - "db_id": self._connections[user_id]["db_id"], + "db_id": self._connections[(workspace_id, db_id)]["db_id"], "url": sanitize_url(url), } except Exception as e: return {"ok": False, "error": _sanitize_db_error(e)} - def _q(self, user_id, n): - c = self._get(user_id) + def _q(self, workspace_id, n): + c = self._get(workspace_id) n = str(n) if c["dialect"] == "mysql": return f"`{n.replace('`', '``')}`" return f'"{n.replace(chr(34), chr(34) * 2)}"' - def get_schema(self, user_id, refresh=False): + def get_schema(self, workspace_id, db_id=None, refresh=False): """Introspect the connected database into the schema dict. STRUCTURE (columns, primary/foreign keys) is collected for EVERY table @@ -207,7 +232,7 @@ def get_schema(self, user_id, refresh=False): distinct values) is capped, at ENRICH_MAX tables, preferring the largest tables when row counts are known. """ - c = self._get(user_id) + c = self._get(workspace_id, db_id=db_id) if c["_schema_cache"] and not refresh: return c["_schema_cache"] inspector = inspect(c["engine"]) @@ -355,7 +380,7 @@ def _sort_key(t): try: res = conn.execute( text( - f"SELECT * FROM {self._q(user_id, tbl)} LIMIT {self.sample_rows}" + f"SELECT * FROM {self._q(workspace_id, tbl)} LIMIT {self.sample_rows}" ) ) names = list(res.keys()) @@ -372,7 +397,7 @@ def _sort_key(t): if rc is None: try: rc = conn.execute( - text(f"SELECT COUNT(*) FROM {self._q(user_id, tbl)}") + text(f"SELECT COUNT(*) FROM {self._q(workspace_id, tbl)}") ).scalar() except Exception as e: logger.warning("Error fetching row count for %s: %s", tbl, e) @@ -390,7 +415,7 @@ def _sort_key(t): try: dv = conn.execute( text( - f"SELECT DISTINCT {self._q(user_id, col['name'])} FROM {self._q(user_id, tbl)} LIMIT 12" + f"SELECT DISTINCT {self._q(workspace_id, col['name'])} FROM {self._q(workspace_id, tbl)} LIMIT 12" ) ).fetchall() vals = [row[0] for row in dv if row[0] is not None] @@ -414,9 +439,9 @@ def _sort_key(t): c["_schema_cache"] = schema return schema - def schema_as_text(self, user_id): - c = self._get(user_id) - schema = self.get_schema(user_id) + def schema_as_text(self, workspace_id, db_id=None): + c = self._get(workspace_id, db_id) + schema = self.get_schema(workspace_id, db_id=db_id) lines = [f"Database dialect: {c['dialect']}"] for tbl, info in schema.items(): rc = ( @@ -441,7 +466,7 @@ def schema_as_text(self, user_id): lines.append(f" sample: {info['sample_rows'][:1]}") return "\n".join(lines) - def _readonly_violation(self, user_id, cleaned): + def _readonly_violation(self, workspace_id, cleaned, db_id=None): """Return an error string if `cleaned` is not a safe read-only query, else None. Primary check parses the SQL into an AST (sqlglot) and rejects anything @@ -453,7 +478,7 @@ def _readonly_violation(self, user_id, cleaned): """ if not cleaned: return "Empty statement." - c = self._get(user_id) + c = self._get(workspace_id, db_id) glot_dialect = _GLOT_DIALECT.get(c["dialect"], c["dialect"]) try: stmts = sqlglot.parse(cleaned, dialect=glot_dialect) @@ -512,11 +537,11 @@ def _apply_statement_timeout(self, conn, dialect): except SQLAlchemyError as e: logger.warning("Could not set statement timeout for %s: %s", dialect, e) - def execute(self, user_id, sql): - c = self._get(user_id) + def execute(self, workspace_id, sql, db_id=None): + c = self._get(workspace_id, db_id) cleaned = sql.strip().rstrip(";").strip() if self.readonly: - violation = self._readonly_violation(user_id, cleaned) + violation = self._readonly_violation(workspace_id, cleaned, db_id) if violation: return {"error": violation, "sql": cleaned} try: @@ -551,14 +576,14 @@ def execute(self, user_id, sql): } return {"error": _sanitize_db_error(e), "sql": cleaned} - def get_db_id(self, user_id): - return self._get(user_id)["db_id"] + def get_db_id(self, workspace_id, db_id=None): + return self._get(workspace_id, db_id)["db_id"] - def get_dialect(self, user_id): - return self._get(user_id)["dialect"] + def get_dialect(self, workspace_id, db_id=None): + return self._get(workspace_id, db_id)["dialect"] - def get_info(self, user_id): - c = self._get(user_id) + def get_info(self, workspace_id, db_id=None): + c = self._get(workspace_id, db_id) return { "url": sanitize_url(c["url"]), "dialect": c["dialect"], diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 8cb07818..8100db28 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -1,7 +1,13 @@ -from fastapi import HTTPException, Cookie, Request +from fastapi import HTTPException, Cookie, Request, Depends, Header import jwt +from sqlalchemy import select from backend.app.secrets import get_jwt_secret +from backend.app.pgdatabase.engine import async_session +from backend.app.models.workspace import WorkspaceMember +from backend.app.models.workspace_settings import WorkspaceSettings +from backend.app.permissions import has_permission +from backend.app.pgdatabase.serialization import _to_uuid async def get_current_user(access_token: str = Cookie(None)): @@ -18,6 +24,79 @@ async def get_current_user(access_token: str = Cookie(None)): raise HTTPException(status_code=401, detail="Invalid Token") +async def get_current_workspace( + x_workspace_id: str = Header(None), user=Depends(get_current_user) +): + if not x_workspace_id: + raise HTTPException(status_code=400, detail="X-Workspace-Id header missing") + try: + wid = _to_uuid(x_workspace_id) + uid = _to_uuid(user.get("user_id", user.get("sub"))) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid workspace_id or user_id") + + async with async_session() as session: + result = await session.execute( + select(WorkspaceMember).where( + WorkspaceMember.workspace_id == wid, WorkspaceMember.user_id == uid + ) + ) + member = result.scalar_one_or_none() + if not member: + raise HTTPException( + status_code=403, detail="Not a member of this workspace" + ) + + return { + "workspace_id": str(member.workspace_id), + "role": member.role, + "user_id": str(member.user_id), + } + + +def require_role(minimum_role: str): + def role_dependency(workspace=Depends(get_current_workspace)): + roles = {"member": 1, "admin": 2, "owner": 3} + user_role = workspace["role"] + if roles.get(user_role, 0) < roles.get(minimum_role, 1): + raise HTTPException(status_code=403, detail="Insufficient permissions") + return workspace + + return role_dependency + + +async def workspace_has_permission(workspace, permission_key: str) -> bool: + """Resolve whether the current workspace member holds a fine-grained permission.""" + if workspace["role"] == "owner": + return True + + wid = _to_uuid(workspace["workspace_id"]) + async with async_session() as session: + result = await session.execute( + select(WorkspaceSettings).where(WorkspaceSettings.workspace_id == wid) + ) + settings = result.scalar_one_or_none() + + role_permissions = getattr(settings, "role_permissions", None) + return has_permission(workspace["role"], role_permissions, permission_key) + + +def require_permission(permission_key: str): + async def permission_dependency(workspace=Depends(get_current_workspace)): + if not await workspace_has_permission(workspace, permission_key): + raise HTTPException( + status_code=403, detail=f"Permission '{permission_key}' required" + ) + + return workspace + + return permission_dependency + + +def get_current_db_id(x_db_id: str = Header(None)): + return x_db_id + + def get_db(request: Request): return request.app.state.db diff --git a/backend/app/llm.py b/backend/app/llm.py index 4e29ab8d..0513a59e 100644 --- a/backend/app/llm.py +++ b/backend/app/llm.py @@ -47,6 +47,15 @@ def parse_json(text): return json.loads(s) +CHART_TYPES = ["table", "bar", "line", "area", "pie", "number"] +DEFAULT_CHART = { + "type": "table", + "x_axis": "", + "y_axis": "", + "title": "", + "reason": "", +} + SQL_SCHEMA = { "name": "sql_result", "schema": { @@ -65,8 +74,37 @@ def parse_json(text): "items": {"type": "string"}, "description": "Any assumptions made while interpreting the question.", }, + "chart": { + "type": "object", + "description": "How this result is best visualised.", + "properties": { + "type": { + "type": "string", + "enum": CHART_TYPES, + "description": "Chart best suited to the shape of the result.", + }, + "x_axis": { + "type": "string", + "description": "Column alias for the category/time axis. Empty for table/number.", + }, + "y_axis": { + "type": "string", + "description": "Column alias for the numeric value. Empty for table.", + }, + "title": { + "type": "string", + "description": "Short chart title, e.g. 'Revenue by month'.", + }, + "reason": { + "type": "string", + "description": "One short clause on why this chart fits.", + }, + }, + "required": ["type", "x_axis", "y_axis", "title", "reason"], + "additionalProperties": False, + }, }, - "required": ["sql", "restatement", "assumptions"], + "required": ["sql", "restatement", "assumptions", "chart"], "additionalProperties": False, }, } @@ -191,6 +229,31 @@ def parse_json(text): } +def parse_chart_spec(raw): + """Normalise the model's chart choice, falling back to a plain table. + + A bad or missing chart must never fail the query — the answer is still + useful rendered as a table. + """ + if not isinstance(raw, dict): + return dict(DEFAULT_CHART) + chart_type = raw.get("type") + if not isinstance(chart_type, str) or chart_type.lower() not in CHART_TYPES: + return dict(DEFAULT_CHART) + + def _text(key): + v = raw.get(key) + return v.strip() if isinstance(v, str) else "" + + return { + "type": chart_type.lower(), + "x_axis": _text("x_axis"), + "y_axis": _text("y_axis"), + "title": _text("title"), + "reason": _text("reason"), + } + + def parse_sql_output(raw): if isinstance(raw, dict): obj = raw @@ -216,7 +279,12 @@ def parse_sql_output(raw): if isinstance(restatement, str) else ("" if restatement is None else str(restatement)) ).strip() - return {"sql": sql, "restatement": restatement, "assumptions": assumptions} + return { + "sql": sql, + "restatement": restatement, + "assumptions": assumptions, + "chart": parse_chart_spec(obj.get("chart")), + } class LLMProvider(ABC): @@ -501,7 +569,7 @@ def build_sql_system_prompt( ): hint = _DIALECT_HINTS.get(dialect, "") return ( - "Answer in English\n" + "CRITICAL: You MUST answer entirely in English. Do not use Chinese or Japanese.\n" f"You are an expert {dialect} analyst. Convert the user's question into " "exactly one read-only SELECT query.\n\n" "Rules:\n" @@ -515,7 +583,19 @@ def build_sql_system_prompt( "[brackets], match those values exactly.\n" "6. Qualify column names with table aliases whenever more than one " "table is involved.\n" - f"7. {hint}\n\n" + f"7. {hint}\n" + "8. Also choose how the result should be visualised, based on the shape " + "of the rows your query returns:\n" + " - line: a measure over time (dates, months, quarters).\n" + " - area: a cumulative or stacked measure over time.\n" + " - bar: a measure compared across categories.\n" + " - pie: parts of one whole, only when there are 2-6 categories and " + "the values are all positive.\n" + " - number: a single row with a single aggregate value.\n" + " - table: anything else — many columns, raw records, or no clear " + "numeric measure. Prefer table when no chart genuinely helps.\n" + " x_axis and y_axis must be column aliases that appear in your SELECT " + "list. Leave them empty for table, and set only y_axis for number.\n\n" f"Schema (col PK = primary key, col->t.c = foreign key, [v1,v2] = " f"example values):\n{schema_text}\n\n" f"{_glossary_block(glossary)}" @@ -526,7 +606,10 @@ def build_sql_system_prompt( '{"sql":"",' '"restatement":"",' '"assumptions":[""]}' + 'or date range; empty list if none>"],' + '"chart":{"type":"","x_axis":"",' + '"y_axis":"","title":"",' + '"reason":""}}' ) @@ -573,6 +656,7 @@ async def shortlist_tables(provider, question, schema, max_columns=4): lines.append(f"{t}({head}{more})") catalog = "\n".join(lines) system = ( + "CRITICAL: You MUST answer entirely in English. Do not use Chinese or Japanese.\n" "You map questions to database tables. Below is the complete catalog " "of tables (name and columns only).\n" "Return every table that might be needed to answer the user's " @@ -607,7 +691,7 @@ async def shortlist_tables(provider, question, schema, max_columns=4): async def explain_sql(provider, sql, dialect): system = ( - "Answer in English\n" + "CRITICAL: You MUST answer entirely in English. Do not use Chinese or Japanese.\n" f"You explain {dialect} SQL to non-technical business users.\n" "Describe what the query returns in 2-4 short plain sentences: which " "data it looks at, how it filters/groups, and how results are ordered " @@ -621,7 +705,7 @@ async def explain_sql(provider, sql, dialect): async def suggest_catalog(provider, schema_text): system = ( - "Answer in English\n" + "CRITICAL: You MUST answer entirely in English. Do not use Chinese or Japanese.\n" "You are a data analyst documenting a database for non-technical users.\n" f"{schema_text}\n\n" "Produce a concise semantic catalog:\n" @@ -660,7 +744,7 @@ async def generate_starters(provider, schema_text, dialect): schema_text[:300], ) system = ( - "Answer in English\n" + "CRITICAL: You MUST answer entirely in English. Do not use Chinese or Japanese.\n" f"You are a database analyst. {dialect} database.\n{schema_text}\n\n" "Generate 3 common useful questions a non-technical user would ask, each with the SQL and " "a one-sentence plain-English description.\n" diff --git a/backend/app/logbook.py b/backend/app/logbook.py index 7192e575..9123b8aa 100644 --- a/backend/app/logbook.py +++ b/backend/app/logbook.py @@ -1,88 +1,26 @@ -"""Local session logging for validation.""" +"""Per-query id minting. + +This used to write a JSONL audit log under the config directory. The durable +record of every query and its feedback now lives in Postgres (`query_history`), +so nothing is written to disk here — the class survives only to hand each query +a short id that feedback is recorded against. +""" -import json -import time import logging -from pathlib import Path -from datetime import datetime -from logging.handlers import RotatingFileHandler +import uuid logger = logging.getLogger(__name__) -MAX_BYTES = 10 * 1024 * 1024 # 10MB -BACKUP_COUNT = 5 - class SessionLog: - def __init__(self, base_dir): - self.dir = Path(base_dir) / "sessions" - self.dir.mkdir(parents=True, exist_ok=True) - stamp = datetime.now().strftime("%Y%m%d-%H%M%S") - self.file = self.dir / f"session-{stamp}.jsonl" - self._setup_rotating_handler() - - def _setup_rotating_handler(self): - self._handler = RotatingFileHandler( - str(self.file), - maxBytes=MAX_BYTES, - backupCount=BACKUP_COUNT, - encoding="utf-8", - ) - - def _append(self, record): - record.update( - { - "ts": time.time(), - "ts_human": datetime.now().isoformat(timespec="seconds"), - } - ) - try: - line = json.dumps(record, default=str) - self._handler.emit( - logging.LogRecord( - name=__name__, - level=logging.INFO, - pathname="", - lineno=0, - msg=line, - args=(), - exc_info=None, - ) - ) - except Exception as e: - logger.error("Failed to append to session log: %s", e) + def __init__(self, base_dir=None): + # base_dir is accepted for backward compatibility and ignored — no files + # are written. + pass def log_query(self, db_id, question, result): - import uuid - - qid = uuid.uuid4().hex[:12] - self._append( - { - "event": "query", - "query_id": qid, - "db_id": db_id, - "question": question, - "sql": result.get("sql"), - "confidence": result.get("confidence"), - "confidence_reason": result.get("confidence_reason"), - "based_on_verified": result.get("based_on_verified"), - "row_count": len(result.get("rows", []) or []), - "execution_error": result.get("execution_error"), - # Audit trail for linking quality: exactly which tables the AI - # was shown (including schema-retry additions) and how many - # generation attempts the repair loop needed. - "tables_used": result.get("tables_used"), - "attempts": result.get("attempts"), - } - ) - return qid + """Return a short id for this query. Persistence is Postgres's job.""" + return uuid.uuid4().hex[:12] def log_feedback(self, query_id, verdict, reason=""): - self._append( - { - "event": "feedback", - "query_id": query_id, - "verdict": verdict, - "reason": reason, - } - ) + return None diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 00000000..424ca446 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,46 @@ +from backend.app.models.base import Base, _utcnow, _uuid7 +from backend.app.models.orm_user import User +from backend.app.models.workspace import Workspace, WorkspaceMember, WorkspaceInvite +from backend.app.models.workspace_settings import WorkspaceSettings +from backend.app.models.activity import WorkspaceActivityLog +from backend.app.models.conversation import Conversation, QueryHistory +from backend.app.models.recent_connection import RecentConnection +from backend.app.models.saved_query import SavedQuery +from backend.app.models.dashboard import Dashboard, DashboardPanel +from backend.app.models.catalog import ( + VerifiedQA, + Glossary, + CatalogColumn, + CatalogMetric, + CatalogJoin, + CatalogSynonym, + CatalogValueMapping, +) +from backend.app.models.auth_token import PasswordResetToken, OtpCode + +__all__ = [ + "Base", + "_utcnow", + "_uuid7", + "User", + "Workspace", + "WorkspaceMember", + "WorkspaceInvite", + "WorkspaceSettings", + "WorkspaceActivityLog", + "Conversation", + "QueryHistory", + "RecentConnection", + "VerifiedQA", + "Glossary", + "CatalogColumn", + "CatalogMetric", + "CatalogJoin", + "CatalogSynonym", + "CatalogValueMapping", + "PasswordResetToken", + "OtpCode", + "SavedQuery", + "Dashboard", + "DashboardPanel", +] diff --git a/backend/app/models/activity.py b/backend/app/models/activity.py new file mode 100644 index 00000000..bd8353aa --- /dev/null +++ b/backend/app/models/activity.py @@ -0,0 +1,34 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, ForeignKey, DateTime, Index +from sqlalchemy.dialects.postgresql import UUID as PgUUID, JSONB +from sqlalchemy.orm import Mapped, mapped_column +from typing import Optional, Any +from backend.app.models.base import Base, _utcnow, _uuid7 + + +class WorkspaceActivityLog(Base): + __tablename__ = "workspace_activity_log" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + actor_id: Mapped[Optional[uuid.UUID]] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + event_type: Mapped[str] = mapped_column(String, nullable=False) + resource_type: Mapped[str] = mapped_column(String, nullable=False) + resource_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) + metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB, default=dict) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow + ) + + __table_args__ = ( + Index("ix_activity_workspace_date", "workspace_id", "created_at"), + Index("ix_activity_workspace_event", "workspace_id", "event_type"), + ) diff --git a/backend/app/models/api.py b/backend/app/models/api.py index 760a02a9..9503aea0 100644 --- a/backend/app/models/api.py +++ b/backend/app/models/api.py @@ -7,6 +7,7 @@ class ConfigUpdate(BaseModel): class ConnectReq(BaseModel): db_url: str + alias_name: str | None = None class ContextTurn(BaseModel): @@ -39,6 +40,8 @@ class FeedbackReq(BaseModel): class RawSQLReq(BaseModel): sql: str conversation_id: str | None = None + # Re-running SQL that is already in history shouldn't create a duplicate entry. + save_history: bool = True class GlossaryItem(BaseModel): diff --git a/backend/app/models/auth_token.py b/backend/app/models/auth_token.py new file mode 100644 index 00000000..58a4b146 --- /dev/null +++ b/backend/app/models/auth_token.py @@ -0,0 +1,48 @@ +import uuid +from datetime import datetime + +from sqlalchemy import String, Boolean, UniqueConstraint, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import UUID as PgUUID +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.models.base import Base, _utcnow, _uuid7 + + +class PasswordResetToken(Base): + __tablename__ = "password_reset_tokens" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + jti_hash: Mapped[str] = mapped_column(String, nullable=False, unique=True) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + consumed: Mapped[bool] = mapped_column(nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow + ) + + +class OtpCode(Base): + __tablename__ = "otp_codes" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + code_hash: Mapped[str] = mapped_column(String, nullable=False) + purpose: Mapped[str] = mapped_column(String, nullable=False) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + used: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow + ) + __table_args__ = ( + UniqueConstraint("user_id", "purpose", name="uq_otp_user_purpose"), + ) diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 00000000..6f71c668 --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,34 @@ +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import DeclarativeBase +import os +import time +import uuid +from datetime import datetime, timezone + + +def _utcnow(): + return datetime.now(timezone.utc) + + +def _uuid7(): + try: + return uuid.uuid7() + except AttributeError: + pass + ts = int(time.time() * 1000) + rand = int.from_bytes(os.urandom(10), "big") + rand_a = rand >> 68 + rand_b = rand & ((1 << 62) - 1) + return uuid.UUID( + int=(ts << 80) | (0x7 << 76) | (rand_a << 64) | (0x2 << 62) | rand_b + ) + + +class Base(DeclarativeBase): + pass + + +@compiles(JSONB, "sqlite") +def _compile_jsonb_sqlite(type_, compiler, **kw): + return "JSON" diff --git a/backend/app/models/catalog.py b/backend/app/models/catalog.py new file mode 100644 index 00000000..d2ce1201 --- /dev/null +++ b/backend/app/models/catalog.py @@ -0,0 +1,136 @@ +import uuid +from datetime import datetime + +from sqlalchemy import String, Text, DateTime, UniqueConstraint, Index, ForeignKey +from sqlalchemy.dialects.postgresql import UUID as PgUUID +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.models.base import Base, _utcnow, _uuid7 + + +class VerifiedQA(Base): + __tablename__ = "verified_qas" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + db_id: Mapped[str] = mapped_column(String, nullable=False) + question: Mapped[str] = mapped_column(Text, nullable=False) + sql: Mapped[str] = mapped_column(Text, nullable=False, default="") + restatement: Mapped[str] = mapped_column(Text, nullable=False, default="") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow + ) + __table_args__ = ( + UniqueConstraint( + "workspace_id", "db_id", "question", name="uq_verified_qa_user_db_question" + ), + Index("ix_verified_qa_user_db", "workspace_id", "db_id"), + ) + + +class Glossary(Base): + __tablename__ = "glossary_terms" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + db_id: Mapped[str] = mapped_column(String, nullable=False) + term: Mapped[str] = mapped_column(String, nullable=False) + maps_to: Mapped[str] = mapped_column(String, nullable=False) + sql_hint: Mapped[str] = mapped_column(String, nullable=False, default="") + __table_args__ = (Index("ix_glossary_user_db", "workspace_id", "db_id"),) + + +class CatalogColumn(Base): + __tablename__ = "catalog_columns" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + db_id: Mapped[str] = mapped_column(String, nullable=False) + table_name: Mapped[str] = mapped_column(String, nullable=False) + column_name: Mapped[str] = mapped_column(String, nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + __table_args__ = (Index("ix_cat_col_user_db", "workspace_id", "db_id"),) + + +class CatalogMetric(Base): + __tablename__ = "catalog_metrics" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + db_id: Mapped[str] = mapped_column(String, nullable=False) + name: Mapped[str] = mapped_column(String, nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False, default="") + sql_expression: Mapped[str] = mapped_column(Text, nullable=False) + __table_args__ = (Index("ix_cat_met_user_db", "workspace_id", "db_id"),) + + +class CatalogJoin(Base): + __tablename__ = "catalog_joins" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + db_id: Mapped[str] = mapped_column(String, nullable=False) + tables: Mapped[str] = mapped_column(String, nullable=False) + join_condition: Mapped[str] = mapped_column(Text, nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False, default="") + __table_args__ = (Index("ix_cat_join_user_db", "workspace_id", "db_id"),) + + +class CatalogSynonym(Base): + __tablename__ = "catalog_synonyms" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + db_id: Mapped[str] = mapped_column(String, nullable=False) + term: Mapped[str] = mapped_column(String, nullable=False) + entity_type: Mapped[str] = mapped_column(String, nullable=False) + entity_name: Mapped[str] = mapped_column(String, nullable=False) + __table_args__ = (Index("ix_cat_syn_user_db", "workspace_id", "db_id"),) + + +class CatalogValueMapping(Base): + __tablename__ = "catalog_value_mappings" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + db_id: Mapped[str] = mapped_column(String, nullable=False) + table_name: Mapped[str] = mapped_column(String, nullable=False) + column_name: Mapped[str] = mapped_column(String, nullable=False) + db_value: Mapped[str] = mapped_column(String, nullable=False) + business_label: Mapped[str] = mapped_column(String, nullable=False) + __table_args__ = (Index("ix_cat_val_user_db", "workspace_id", "db_id"),) diff --git a/backend/app/models/conversation.py b/backend/app/models/conversation.py new file mode 100644 index 00000000..d8de0002 --- /dev/null +++ b/backend/app/models/conversation.py @@ -0,0 +1,67 @@ +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import String, Text, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import JSONB, UUID as PgUUID +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.models.base import Base, _utcnow, _uuid7 + + +class Conversation(Base): + __tablename__ = "conversations" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + # Owning user. Conversations are private to the user that created them, + # scoped within their workspace — other members cannot see them. + user_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + title: Mapped[str] = mapped_column(String, nullable=False, default="") + database_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow + ) + + +class QueryHistory(Base): + __tablename__ = "query_history" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + user_id: Mapped[Optional[uuid.UUID]] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True + ) + question: Mapped[str] = mapped_column(Text, nullable=False) + sql: Mapped[str] = mapped_column(Text, nullable=False, default="") + result: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + confidence: Mapped[str] = mapped_column(String, nullable=False, default="low") + restatement: Mapped[str] = mapped_column(Text, nullable=False, default="") + # The model's chart choice for this result. Null for turns recorded before + # charts were part of the SQL generation output. + chart: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + conversation_id: Mapped[Optional[uuid.UUID]] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("conversations.id", ondelete="SET NULL"), + nullable=True, + ) + timestamp: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow + ) diff --git a/backend/app/models/dashboard.py b/backend/app/models/dashboard.py new file mode 100644 index 00000000..f2f17a14 --- /dev/null +++ b/backend/app/models/dashboard.py @@ -0,0 +1,45 @@ +from sqlalchemy import Column, String, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import relationship + +from backend.app.models.base import Base, _utcnow, _uuid7 + + +class Dashboard(Base): + __tablename__ = "dashboards" + id = Column(UUID(as_uuid=True), primary_key=True, default=_uuid7) + workspace_id = Column( + UUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + name = Column(String(255), nullable=False) + description = Column(String) + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")) + created_at = Column(DateTime(timezone=True), default=_utcnow) + updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow) + + panels = relationship( + "DashboardPanel", back_populates="dashboard", cascade="all, delete" + ) + + +class DashboardPanel(Base): + __tablename__ = "dashboard_panels" + id = Column(UUID(as_uuid=True), primary_key=True, default=_uuid7) + dashboard_id = Column( + UUID(as_uuid=True), + ForeignKey("dashboards.id", ondelete="CASCADE"), + nullable=False, + ) + saved_query_id = Column( + UUID(as_uuid=True), ForeignKey("saved_queries.id", ondelete="SET NULL") + ) + title = Column(String(255)) + visualization_type = Column(String) + viz_config = Column(JSONB, default=lambda: {}) + position = Column(JSONB, default=lambda: {"x": 0, "y": 0, "w": 4, "h": 4}) + created_at = Column(DateTime(timezone=True), default=_utcnow) + updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow) + + dashboard = relationship("Dashboard", back_populates="panels") diff --git a/backend/app/models/orm_user.py b/backend/app/models/orm_user.py new file mode 100644 index 00000000..d39eeae0 --- /dev/null +++ b/backend/app/models/orm_user.py @@ -0,0 +1,34 @@ +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import Boolean, String, DateTime +from sqlalchemy.dialects.postgresql import UUID as PgUUID, JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.models.base import Base, _utcnow, _uuid7 + + +class User(Base): + __tablename__ = "users" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + email: Mapped[str] = mapped_column(String, unique=True, nullable=False) + hashed_pass: Mapped[str] = mapped_column(String, nullable=False, default="") + role: Mapped[str] = mapped_column(String, nullable=False, default="user") + google_id: Mapped[Optional[str]] = mapped_column(String, unique=True, nullable=True) + supabase_id: Mapped[Optional[str]] = mapped_column( + String, unique=True, nullable=True + ) + email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + first_name: Mapped[Optional[str]] = mapped_column(String, nullable=True) + last_name: Mapped[Optional[str]] = mapped_column(String, nullable=True) + avatar_url: Mapped[Optional[str]] = mapped_column(String, nullable=True) + metadata_: Mapped[Optional[dict]] = mapped_column( + "metadata", JSONB, nullable=True, default=dict + ) + tour_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow + ) diff --git a/backend/app/models/recent_connection.py b/backend/app/models/recent_connection.py new file mode 100644 index 00000000..cd515b4d --- /dev/null +++ b/backend/app/models/recent_connection.py @@ -0,0 +1,31 @@ +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import String, Integer, Text, UniqueConstraint, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import UUID as PgUUID +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.models.base import Base, _utcnow, _uuid7 + + +class RecentConnection(Base): + __tablename__ = "recent_connections" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + db_url: Mapped[str] = mapped_column(Text, nullable=False) + display_url: Mapped[str] = mapped_column(String, nullable=False) + alias_name: Mapped[Optional[str]] = mapped_column(String, nullable=True) + dialect: Mapped[str] = mapped_column(String, nullable=False) + db_id: Mapped[str] = mapped_column(String, nullable=False) + table_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + connected_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow + ) + __table_args__ = (UniqueConstraint("workspace_id", "db_id"),) diff --git a/backend/app/models/saved_query.py b/backend/app/models/saved_query.py new file mode 100644 index 00000000..80409d55 --- /dev/null +++ b/backend/app/models/saved_query.py @@ -0,0 +1,26 @@ +from sqlalchemy import Column, String, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import JSONB, UUID + +from backend.app.models.base import Base, _utcnow, _uuid7 + + +class SavedQuery(Base): + __tablename__ = "saved_queries" + id = Column(UUID(as_uuid=True), primary_key=True, default=_uuid7) + workspace_id = Column( + UUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")) + name = Column(String(255), nullable=False) + description = Column(String) + question = Column(String) + sql = Column(String) + columns = Column(JSONB) + database_id = Column(String) + visualization_type = Column(String, default="table") + viz_config = Column(JSONB, default=lambda: {}) + last_result = Column(JSONB) + created_at = Column(DateTime(timezone=True), default=_utcnow) + updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 94a67c61..4c6651f8 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,6 +1,6 @@ from enum import Enum -from typing import Optional -from pydantic import BaseModel, EmailStr, field_validator +from typing import Any, Optional +from pydantic import BaseModel, EmailStr, field_validator, AnyHttpUrl, Field class Role(Enum): @@ -48,12 +48,20 @@ class UserInDB(BaseModel): google_id: Optional[str] = None supabase_id: Optional[str] = None email_verified: bool = False + first_name: Optional[str] = None + last_name: Optional[str] = None + avatar_url: Optional[AnyHttpUrl] = None + metadata: Optional[dict[str, Any]] = Field(None, max_length=50) class UserPublic(BaseModel): id: str = "" email: EmailStr role: Role + first_name: Optional[str] = None + last_name: Optional[str] = None + avatar_url: Optional[AnyHttpUrl] = None + metadata: Optional[dict[str, Any]] = Field(None, max_length=50) class SupabaseLogin(BaseModel): @@ -66,3 +74,7 @@ class VerifyEmailOTP(BaseModel): class UpdateProfile(BaseModel): email: Optional[EmailStr] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + avatar_url: Optional[AnyHttpUrl] = None + metadata: Optional[dict[str, Any]] = Field(None, max_length=50) diff --git a/backend/app/models/workspace.py b/backend/app/models/workspace.py new file mode 100644 index 00000000..87f27516 --- /dev/null +++ b/backend/app/models/workspace.py @@ -0,0 +1,102 @@ +""" +New: workspaces +id, name, slug, created_by (FK users), created_at + +New: workspace_members — this is the crux of the whole system +id, workspace_id (FK), user_id (FK), role (enum: owner / admin / member), joined_at, invited_by +Unique constraint on (workspace_id, user_id). Index on user_id alone too — you'll query "what workspaces is this user in" constantly for the workspace switcher. + +New: workspace_invites (can trail slightly behind if you want to ship core scoping first) +id, workspace_id, email, role, token, invited_by, expires_at, accepted_at + +Modified (9 tables): swap user_id → workspace_id, rebuild the (user_id, db_id) indexes as (workspace_id, db_id). QueryHistory gets both workspace_id and keeps user_id. +""" + +import uuid +from datetime import datetime +from sqlalchemy import ( + String, + UniqueConstraint, + CheckConstraint, + ForeignKey, + DateTime, + Index, +) +from typing import Optional +from sqlalchemy.dialects.postgresql import UUID as PgUUID +from sqlalchemy.orm import Mapped, mapped_column +from backend.app.models.base import Base, _utcnow, _uuid7 + + +class WorkspaceMember(Base): + __tablename__ = "workspace_members" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + user_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + role: Mapped[str] = mapped_column(String, nullable=False, default="member") + invited_by: Mapped[Optional[uuid.UUID]] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + joined_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow + ) + __table_args__ = ( + UniqueConstraint("workspace_id", "user_id", name="uq_workspace_member"), + CheckConstraint( + "role IN ('owner', 'admin', 'member')", name="ck_workspace_member_role" + ), + Index("ix_workspace_member_user", "user_id"), + ) + + +class WorkspaceInvite(Base): + __tablename__ = "workspace_invites" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + email: Mapped[str] = mapped_column(String, nullable=False) + role: Mapped[str] = mapped_column(String, nullable=False, default="member") + token: Mapped[str] = mapped_column(String, unique=True, nullable=False) + invited_by: Mapped[Optional[uuid.UUID]] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + accepted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + __table_args__ = ( + UniqueConstraint("workspace_id", "email", name="uq_workspace_invite_email"), + CheckConstraint( + "role IN ('owner', 'admin', 'member')", name="ck_workspace_invite_role" + ), + ) + + +class Workspace(Base): + __tablename__ = "workspaces" + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=_uuid7 + ) + name: Mapped[str] = mapped_column(String, nullable=False) + slug: Mapped[str] = mapped_column(String, unique=True, nullable=False) + created_by: Mapped[Optional[uuid.UUID]] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow + ) diff --git a/backend/app/models/workspace_api.py b/backend/app/models/workspace_api.py new file mode 100644 index 00000000..64f21569 --- /dev/null +++ b/backend/app/models/workspace_api.py @@ -0,0 +1,93 @@ +from typing import Any, Literal, Optional +from datetime import datetime +from pydantic import BaseModel, EmailStr, Field, field_validator + +RoleLiteral = Literal["admin", "member"] + +# Kept in sync with the client-side validator in `frontend/src/lib/validation.ts` +# so the two agree on what counts as a usable workspace name. +WORKSPACE_NAME_MIN = 2 +WORKSPACE_NAME_MAX = 60 + +# Upper bound on a single bulk-invite request, so one paste of a huge CSV can't +# turn into an unbounded run of invite emails. +MAX_BULK_INVITES = 100 + + +def _validate_workspace_name(value: str) -> str: + trimmed = (value or "").strip() + if len(trimmed) < WORKSPACE_NAME_MIN: + raise ValueError( + f"Workspace name must be at least {WORKSPACE_NAME_MIN} characters" + ) + if len(trimmed) > WORKSPACE_NAME_MAX: + raise ValueError( + f"Workspace name must be at most {WORKSPACE_NAME_MAX} characters" + ) + return trimmed + + +class WorkspaceCreate(BaseModel): + name: str + + @field_validator("name") + @classmethod + def check_name(cls, v: str) -> str: + return _validate_workspace_name(v) + + +class WorkspaceMemberRoleUpdate(BaseModel): + role: RoleLiteral + + +class WorkspaceInviteCreate(BaseModel): + email: EmailStr + role: RoleLiteral = "member" + + +class WorkspaceBulkInviteCreate(BaseModel): + # Plain strings, not EmailStr: a single malformed address should come back + # as one `invalid` row rather than 422-ing the whole batch. + emails: list[str] = Field(min_length=1, max_length=MAX_BULK_INVITES) + role: RoleLiteral = "member" + + +class WorkspaceOwnershipTransfer(BaseModel): + user_id: str + + +class WorkspaceUpdate(BaseModel): + name: str | None = None + + @field_validator("name") + @classmethod + def check_name(cls, v: str | None) -> str | None: + return None if v is None else _validate_workspace_name(v) + + +class ActivityLogResponse(BaseModel): + id: str + workspace_id: str + actor_id: Optional[str] = None + actor_email: Optional[str] = None + event_type: str + resource_type: str + resource_id: Optional[str] = None + metadata_: dict[str, Any] + created_at: datetime + + +class WorkspaceSettingsUpdate(BaseModel): + default_invite_role: Optional[RoleLiteral] = None + invite_expiry_days: Optional[int] = Field(None, ge=1, le=365) + activity_retention_days: Optional[int] = Field(None, ge=1, le=365) + role_permissions: Optional[dict[str, Any]] = None + + +class WorkspaceSettingsResponse(BaseModel): + workspace_id: str + default_invite_role: str + invite_expiry_days: int + activity_retention_days: int + role_permissions: dict[str, Any] + resolved_matrix: dict[str, dict[str, bool]] diff --git a/backend/app/models/workspace_settings.py b/backend/app/models/workspace_settings.py new file mode 100644 index 00000000..f485bb2d --- /dev/null +++ b/backend/app/models/workspace_settings.py @@ -0,0 +1,39 @@ +import uuid +from datetime import datetime + +from sqlalchemy import String, Integer, ForeignKey, DateTime +from sqlalchemy.dialects.postgresql import UUID as PgUUID, JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.models.base import Base, _utcnow + + +class WorkspaceSettings(Base): + __tablename__ = "workspace_settings" + + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + primary_key=True, + ) + default_invite_role: Mapped[str] = mapped_column( + String(20), nullable=False, default="member" + ) + invite_expiry_days: Mapped[int] = mapped_column(Integer, nullable=False, default=7) + activity_retention_days: Mapped[int] = mapped_column( + Integer, nullable=False, default=30 + ) + role_permissions: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow, nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False + ) + + def __init__(self, **kw): + kw.setdefault("default_invite_role", "member") + kw.setdefault("invite_expiry_days", 7) + kw.setdefault("activity_retention_days", 30) + kw.setdefault("role_permissions", {}) + super().__init__(**kw) diff --git a/backend/app/permissions.py b/backend/app/permissions.py new file mode 100644 index 00000000..9eb49ece --- /dev/null +++ b/backend/app/permissions.py @@ -0,0 +1,256 @@ +""" +RBAC Permissions Registry and Resolution Utilities for BoloDB. + +Defines 21 fine-grained capabilities categorized across 7 resources: +- members: members.view, members.invite, members.update_role, members.remove +- connections: connections.view, connections.manage, connections.view_schema +- catalog: catalog.view, catalog.manage +- dashboards: dashboards.view, dashboards.create, dashboards.manage +- queries: queries.execute, queries.explain, queries.save, queries.delete_saved +- activity: activity.view, activity.export +- workspace_management: workspace.view, workspace.update, workspace.settings +""" + +from typing import Any, Dict, Optional, Union + +PERMISSIONS: Dict[str, Dict[str, Any]] = { + # Resource: members + "members.view": { + "key": "members.view", + "name": "View Members", + "category": "members", + "description": "View workspace member list and member details", + "default_roles": ["owner", "admin", "member"], + }, + "members.invite": { + "key": "members.invite", + "name": "Invite Members", + "category": "members", + "description": "Invite new members to join the workspace", + "default_roles": ["owner", "admin"], + }, + "members.update_role": { + "key": "members.update_role", + "name": "Update Member Roles", + "category": "members", + "description": "Change roles of workspace members", + "default_roles": ["owner", "admin"], + }, + "members.remove": { + "key": "members.remove", + "name": "Remove Members", + "category": "members", + "description": "Remove members from the workspace", + "default_roles": ["owner", "admin"], + }, + # Resource: connections + "connections.view": { + "key": "connections.view", + "name": "View Connections", + "category": "connections", + "description": "View configured database connections", + "default_roles": ["owner", "admin", "member"], + }, + "connections.manage": { + "key": "connections.manage", + "name": "Manage Connections", + "category": "connections", + "description": "Create, edit, or delete database connections", + "default_roles": ["owner", "admin"], + }, + "connections.view_schema": { + "key": "connections.view_schema", + "name": "View Connection Schema", + "category": "connections", + "description": "View schema and metadata for database connections", + "default_roles": ["owner", "admin", "member"], + }, + # Resource: catalog + "catalog.view": { + "key": "catalog.view", + "name": "View Catalog", + "category": "catalog", + "description": "View data catalog, verified Q&A, and metrics", + "default_roles": ["owner", "admin", "member"], + }, + "catalog.manage": { + "key": "catalog.manage", + "name": "Manage Catalog", + "category": "catalog", + "description": "Create or update data catalog definitions", + "default_roles": ["owner", "admin"], + }, + # Resource: dashboards + "dashboards.view": { + "key": "dashboards.view", + "name": "View Dashboards", + "category": "dashboards", + "description": "View dashboards and visualization panels", + "default_roles": ["owner", "admin", "member"], + }, + "dashboards.create": { + "key": "dashboards.create", + "name": "Create Dashboards", + "category": "dashboards", + "description": "Create new dashboards and panels", + "default_roles": ["owner", "admin"], + }, + "dashboards.manage": { + "key": "dashboards.manage", + "name": "Manage Dashboards", + "category": "dashboards", + "description": "Edit or delete existing dashboards", + "default_roles": ["owner", "admin"], + }, + # Resource: queries + "queries.execute": { + "key": "queries.execute", + "name": "Execute Queries", + "category": "queries", + "description": "Execute natural language and SQL queries", + "default_roles": ["owner", "admin", "member"], + }, + "queries.explain": { + "key": "queries.explain", + "name": "Explain Queries", + "category": "queries", + "description": "Generate query explanations and execution plans", + "default_roles": ["owner", "admin", "member"], + }, + "queries.save": { + "key": "queries.save", + "name": "Save Queries", + "category": "queries", + "description": "Save queries for workspace access", + "default_roles": ["owner", "admin", "member"], + }, + "queries.delete_saved": { + "key": "queries.delete_saved", + "name": "Delete Saved Queries", + "category": "queries", + "description": "Delete saved queries", + "default_roles": ["owner", "admin"], + }, + # Resource: activity + "activity.view": { + "key": "activity.view", + "name": "View Activity", + "category": "activity", + "description": "View workspace activity logs", + "default_roles": ["owner", "admin"], + }, + "activity.export": { + "key": "activity.export", + "name": "Export Activity", + "category": "activity", + "description": "Export workspace activity logs", + "default_roles": ["owner", "admin"], + }, + # Resource: workspace_management + "workspace.view": { + "key": "workspace.view", + "name": "View Workspace", + "category": "workspace_management", + "description": "View workspace details and configuration", + "default_roles": ["owner", "admin", "member"], + }, + "workspace.update": { + "key": "workspace.update", + "name": "Update Workspace", + "category": "workspace_management", + "description": "Update workspace basic profile details", + "default_roles": ["owner", "admin"], + }, + "workspace.settings": { + "key": "workspace.settings", + "name": "Manage Workspace Settings", + "category": "workspace_management", + "description": "Manage workspace defaults and role permission matrix", + "default_roles": ["owner", "admin"], + }, +} + +MEMBER_DEFAULT_PERMISSIONS: set[str] = { + "members.view", + "connections.view", + "connections.view_schema", + "catalog.view", + "dashboards.view", + "queries.execute", + "queries.explain", + "queries.save", + "workspace.view", +} + +DEFAULT_ROLE_PERMISSIONS: Dict[str, Dict[str, bool]] = { + "owner": {perm_key: True for perm_key in PERMISSIONS}, + "admin": {perm_key: True for perm_key in PERMISSIONS}, + "member": { + perm_key: (perm_key in MEMBER_DEFAULT_PERMISSIONS) for perm_key in PERMISSIONS + }, +} + + +def resolve_role_permissions( + role: str, overrides: Optional[Dict[str, Any]] = None +) -> Dict[str, bool]: + """ + Resolve the complete map of permission capabilities for a given role, applying + sparse overrides if provided. + + The 'owner' role always has all permissions set to True (non-editable bypass). + """ + if role == "owner": + return {perm_key: True for perm_key in PERMISSIONS} + + base = dict( + DEFAULT_ROLE_PERMISSIONS.get( + role, {perm_key: False for perm_key in PERMISSIONS} + ) + ) + + if not overrides: + return base + + # Extract role-specific overrides dictionary + role_overrides = None + if role in overrides and isinstance(overrides[role], dict): + role_overrides = overrides[role] + elif any(k in PERMISSIONS for k in overrides.keys()): + role_overrides = overrides + + if isinstance(role_overrides, dict): + for perm_key, enabled in role_overrides.items(): + if perm_key in PERMISSIONS and isinstance(enabled, bool): + base[perm_key] = enabled + + return base + + +def has_permission( + role: str, + overrides: Optional[Union[Dict[str, Any], str]] = None, + permission_key: Optional[str] = None, +) -> bool: + """ + Check if a specific role possesses a fine-grained capability key. + + Supports dual invocation formats: + - has_permission(role, overrides_dict, "queries.execute") + - has_permission(role, "queries.execute") [when no overrides provided] + - has_permission(role, overrides=overrides_dict, permission_key="queries.execute") + """ + if isinstance(overrides, str) and permission_key is None: + permission_key = overrides + overrides = None + + if not permission_key or permission_key not in PERMISSIONS: + return False + + if role == "owner": + return True + + resolved = resolve_role_permissions( + role, overrides if isinstance(overrides, dict) else None + ) + return resolved.get(permission_key, False) diff --git a/backend/app/pgdatabase/__init__.py b/backend/app/pgdatabase/__init__.py index d2914d82..6754f41d 100644 --- a/backend/app/pgdatabase/__init__.py +++ b/backend/app/pgdatabase/__init__.py @@ -1,7 +1,7 @@ """PostgreSQL application state: users, conversations, history, connections.""" from backend.app.pgdatabase.engine import get_engine, dispose_db, async_session -from backend.app.pgdatabase.models import ( +from backend.app.models import ( Base, User, Conversation, @@ -16,6 +16,9 @@ CatalogJoin, CatalogSynonym, CatalogValueMapping, + Workspace, + WorkspaceMember, + WorkspaceInvite, ) from backend.app.pgdatabase.users import ( get_user_by_email, @@ -38,6 +41,8 @@ get_recent_connections, delete_recent_connection, get_recent_connection_by_db_id, + get_latest_recent_connection, + update_recent_connection_alias, ) from backend.app.pgdatabase.conversations import ( create_conversation, @@ -70,6 +75,9 @@ "CatalogJoin", "CatalogSynonym", "CatalogValueMapping", + "Workspace", + "WorkspaceMember", + "WorkspaceInvite", "get_user_by_email", "get_user_by_google_id", "get_user_by_supabase_id", @@ -86,6 +94,8 @@ "get_recent_connections", "delete_recent_connection", "get_recent_connection_by_db_id", + "get_latest_recent_connection", + "update_recent_connection_alias", "create_conversation", "conversation_owned_by", "get_conversations", diff --git a/backend/app/pgdatabase/connections.py b/backend/app/pgdatabase/connections.py index 60ec9bb6..378b4634 100644 --- a/backend/app/pgdatabase/connections.py +++ b/backend/app/pgdatabase/connections.py @@ -1,4 +1,12 @@ -"""Recent connections CRUD with encryption.""" +"""Recent connections CRUD with encryption. + +Stored database URLs contain credentials, so they are encrypted at rest with a +key derived from ``RECENT_CONNECTIONS_KEY`` in the environment. That is the only +source — nothing is written to disk, so pointing a deploy at a fresh volume or +rotating the app image can never orphan the key. The env var must stay stable: +change it and previously saved connections can no longer be decrypted and have +to be re-added. +""" import base64 import hashlib @@ -10,69 +18,28 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert from backend.app.pgdatabase.engine import async_session -from backend.app.pgdatabase.models import RecentConnection +from backend.app.models.recent_connection import RecentConnection +from backend.app.models.base import _uuid7 from backend.app.pgdatabase.serialization import _to_uuid, serialize_doc -from backend.app.pgdatabase.models import _uuid7 -from backend.app.config import CONFIG_DIR - -_CONNECTIONS_KEY_FILE = CONFIG_DIR / "connections.key" +class ConnectionKeyError(RuntimeError): + """Raised when RECENT_CONNECTIONS_KEY is missing or unusable. -def _recent_connections_master_cipher(): - master = os.getenv("RECENT_CONNECTIONS_MASTER_KEY") - if not master: - return None - master_key = base64.urlsafe_b64encode(hashlib.sha256(master.encode()).digest()) - return Fernet(master_key) + A dedicated type so callers can tell "the server is misconfigured" (every + save will fail until an operator fixes the environment) apart from a + transient database error (worth retrying). + """ def _build_recent_connection_cipher(): secret = os.getenv("RECENT_CONNECTIONS_KEY") - if secret: - key = base64.urlsafe_b64encode(hashlib.sha256(secret.encode()).digest()) - return Fernet(key) - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - master_cipher = _recent_connections_master_cipher() - if _CONNECTIONS_KEY_FILE.exists(): - persisted = _CONNECTIONS_KEY_FILE.read_text().strip() - if master_cipher: - if persisted.startswith("v1:"): - try: - loaded_secret = master_cipher.decrypt( - persisted[3:].encode() - ).decode() - except (InvalidToken, ValueError, TypeError): - raise RuntimeError( - "Failed to decrypt connections key file with RECENT_CONNECTIONS_MASTER_KEY. " - "The master key may have changed or the key file is corrupted." - ) - else: - raise RuntimeError( - "RECENT_CONNECTIONS_MASTER_KEY is set but connections key file " - "is not encrypted. Re-run with RECENT_CONNECTIONS_MASTER_KEY unset " - "to regenerate, or set RECENT_CONNECTIONS_KEY directly." - ) - else: - loaded_secret = persisted - key = base64.urlsafe_b64encode(hashlib.sha256(loaded_secret.encode()).digest()) - return Fernet(key) - if master_cipher: - new_secret = base64.urlsafe_b64encode(os.urandom(32)).decode() - _CONNECTIONS_KEY_FILE.write_text( - "v1:" + master_cipher.encrypt(new_secret.encode()).decode() - ) - try: - os.chmod(_CONNECTIONS_KEY_FILE, 0o600) - except OSError: - pass - key = base64.urlsafe_b64encode(hashlib.sha256(new_secret.encode()).digest()) - else: - raise RuntimeError( - "No encryption key configured for recent connections. Set " - "RECENT_CONNECTIONS_KEY or RECENT_CONNECTIONS_MASTER_KEY (or " - "both) in the environment, or set JWT_SECRET for migration." + if not secret: + raise ConnectionKeyError( + "RECENT_CONNECTIONS_KEY is not set. It is required to encrypt saved " + "database connections — set a stable secret in the environment." ) + key = base64.urlsafe_b64encode(hashlib.sha256(secret.encode()).digest()) return Fernet(key) @@ -91,9 +58,13 @@ def _encrypt_connection_url(db_url): def _decrypt_connection_url(value): + # A missing or unusable key configuration must not short-circuit the legacy + # and plaintext paths below — rows written before the current key scheme are + # still readable through them, and that migration path is the whole point of + # the fallbacks. Hence RuntimeError is caught alongside the Fernet errors. try: return _recent_connection_cipher().decrypt(value.encode()).decode() - except (InvalidToken, ValueError, TypeError): + except (InvalidToken, ValueError, TypeError, RuntimeError): pass jwt_secret = os.getenv("JWT_SECRET") if jwt_secret: @@ -113,9 +84,9 @@ def _decrypt_connection_url(value): async def save_recent_connection( - user_id, db_url, display_url, dialect, db_id, table_count + workspace_id, db_url, display_url, dialect, db_id, table_count, alias_name=None ): - uid = _to_uuid(user_id) + wid = _to_uuid(workspace_id) encrypted_url = _encrypt_connection_url(db_url) async with async_session() as session: try: @@ -123,20 +94,26 @@ async def save_recent_connection( pg_insert(RecentConnection) .values( id=_uuid7(), - user_id=uid, + workspace_id=wid, db_url=encrypted_url, display_url=display_url, dialect=dialect, db_id=db_id, table_count=table_count, + alias_name=alias_name, ) .on_conflict_do_update( - index_elements=["user_id", "db_id"], + index_elements=["workspace_id", "db_id"], set_=dict( db_url=encrypted_url, display_url=display_url, dialect=dialect, table_count=table_count, + alias_name=( + alias_name + if alias_name is not None + else RecentConnection.alias_name + ), connected_at=func.now(), ), ) @@ -148,34 +125,58 @@ async def save_recent_connection( raise -async def get_recent_connections(user_id: str, limit: int = 5): - uid = _to_uuid(user_id) +def _serialize_connection(row, include_url: bool = False) -> dict: + d = { + "id": row.id, + "workspace_id": row.workspace_id, + "display_url": row.display_url, + "alias_name": row.alias_name, + "dialect": row.dialect, + "db_id": row.db_id, + "table_count": row.table_count, + "connected_at": row.connected_at, + } + if include_url: + d["db_url"] = _decrypt_connection_url(row.db_url) + return serialize_doc(d) + + +async def get_recent_connections(workspace_id: str, limit: int = 50): + wid = _to_uuid(workspace_id) async with async_session() as session: result = await session.execute( select(RecentConnection) - .where(RecentConnection.user_id == uid) + .where(RecentConnection.workspace_id == wid) .order_by(RecentConnection.connected_at.desc()) .limit(limit) ) - rows = result.scalars().all() - out = [] - for row in rows: - d = { - "id": row.id, - "user_id": row.user_id, - "display_url": row.display_url, - "dialect": row.dialect, - "db_id": row.db_id, - "table_count": row.table_count, - "connected_at": row.connected_at, - } - out.append(serialize_doc(d)) - return out - - -async def delete_recent_connection(user_id: str, connection_id: str) -> bool: + return [_serialize_connection(row) for row in result.scalars().all()] + + +async def get_latest_recent_connection(workspace_id: str) -> Optional[dict]: + """The workspace's most recently used connection, with its URL decrypted. + + Used to restore a workspace's database after a server restart, when nothing + tells us *which* database to reconnect to. + """ + try: + wid = _to_uuid(workspace_id) + except (ValueError, TypeError): + return None + async with async_session() as session: + result = await session.execute( + select(RecentConnection) + .where(RecentConnection.workspace_id == wid) + .order_by(RecentConnection.connected_at.desc()) + .limit(1) + ) + row = result.scalar_one_or_none() + return _serialize_connection(row, include_url=True) if row else None + + +async def delete_recent_connection(workspace_id: str, connection_id: str) -> bool: try: - uid = _to_uuid(user_id) + wid = _to_uuid(workspace_id) cid = _to_uuid(connection_id) except (ValueError, TypeError): return False @@ -183,7 +184,7 @@ async def delete_recent_connection(user_id: str, connection_id: str) -> bool: try: result = await session.execute( delete(RecentConnection).where( - RecentConnection.id == cid, RecentConnection.user_id == uid + RecentConnection.id == cid, RecentConnection.workspace_id == wid ) ) await session.commit() @@ -193,27 +194,42 @@ async def delete_recent_connection(user_id: str, connection_id: str) -> bool: raise -async def get_recent_connection_by_db_id(user_id: str, db_id: str) -> Optional[dict]: - uid = _to_uuid(user_id) +async def get_recent_connection_by_db_id( + workspace_id: str, db_id: str +) -> Optional[dict]: + try: + wid = _to_uuid(workspace_id) + except (ValueError, TypeError): + return None async with async_session() as session: result = await session.execute( select(RecentConnection).where( - RecentConnection.user_id == uid, RecentConnection.db_id == db_id + RecentConnection.workspace_id == wid, RecentConnection.db_id == db_id ) ) conn = result.scalar_one_or_none() - if conn is None: - return None - d = { - "id": conn.id, - "user_id": conn.user_id, - "db_url": conn.db_url, - "display_url": conn.display_url, - "dialect": conn.dialect, - "db_id": conn.db_id, - "table_count": conn.table_count, - "connected_at": conn.connected_at, - } - if "db_url" in d: - d["db_url"] = _decrypt_connection_url(d["db_url"]) - return serialize_doc(d) + return _serialize_connection(conn, include_url=True) if conn else None + + +async def update_recent_connection_alias( + workspace_id: str, connection_id: str, alias_name: str | None +) -> bool: + try: + wid = _to_uuid(workspace_id) + cid = _to_uuid(connection_id) + except (ValueError, TypeError): + return False + from sqlalchemy import update + + async with async_session() as session: + try: + result = await session.execute( + update(RecentConnection) + .where(RecentConnection.id == cid, RecentConnection.workspace_id == wid) + .values(alias_name=alias_name) + ) + await session.commit() + return result.rowcount > 0 + except Exception: + await session.rollback() + raise diff --git a/backend/app/pgdatabase/conversations.py b/backend/app/pgdatabase/conversations.py index 8e7eca33..98a180ac 100644 --- a/backend/app/pgdatabase/conversations.py +++ b/backend/app/pgdatabase/conversations.py @@ -6,7 +6,8 @@ from sqlalchemy import select, delete, update, text from backend.app.pgdatabase.engine import async_session -from backend.app.pgdatabase.models import Conversation, QueryHistory, _utcnow +from backend.app.models.conversation import Conversation, QueryHistory +from backend.app.models.base import _utcnow from backend.app.pgdatabase.serialization import _to_uuid, serialize_doc # Max result rows returned per turn when restoring a conversation. @@ -15,12 +16,14 @@ MAX_SAVED_ROWS = 500 -async def create_conversation(user_id, title="", database_id=None): - uid = _to_uuid(user_id) +async def create_conversation(workspace_id, user_id, title="", database_id=None): + uid = _to_uuid(workspace_id) + usr = _to_uuid(user_id) async with async_session() as session: try: conv = Conversation( - user_id=uid, + workspace_id=uid, + user_id=usr, title=title, database_id=database_id, ) @@ -32,7 +35,8 @@ async def create_conversation(user_id, title="", database_id=None): raise d = { "id": conv.id, - "user_id": conv.user_id, + "_id": conv.id, + "workspace_id": conv.workspace_id, "title": conv.title, "database_id": conv.database_id, "created_at": conv.created_at, @@ -41,27 +45,33 @@ async def create_conversation(user_id, title="", database_id=None): return serialize_doc(d) -async def conversation_owned_by(user_id: str, conversation_id: str) -> bool: +async def conversation_owned_by( + workspace_id: str, user_id: str, conversation_id: str +) -> bool: try: - uid = _to_uuid(user_id) + uid = _to_uuid(workspace_id) + usr = _to_uuid(user_id) cid = _to_uuid(conversation_id) except (ValueError, TypeError): return False async with async_session() as session: result = await session.execute( select(Conversation.id).where( - Conversation.id == cid, Conversation.user_id == uid + Conversation.id == cid, + Conversation.workspace_id == uid, + Conversation.user_id == usr, ) ) return result.scalar_one_or_none() is not None -async def get_conversations(user_id: str): - uid = _to_uuid(user_id) +async def get_conversations(workspace_id: str, user_id: str): + uid = _to_uuid(workspace_id) + usr = _to_uuid(user_id) async with async_session() as session: conv_result = await session.execute( select(Conversation) - .where(Conversation.user_id == uid) + .where(Conversation.workspace_id == uid, Conversation.user_id == usr) .order_by(Conversation.updated_at.desc()) ) convs = conv_result.scalars().all() @@ -76,7 +86,7 @@ async def get_conversations(user_id: str): "SELECT qh.conversation_id, COUNT(*) AS turn_count, " "MAX(qh.timestamp) AS last_ts " "FROM query_history qh " - "WHERE qh.user_id = :uid AND qh.conversation_id = ANY(CAST(:cids AS uuid[])) " + "WHERE qh.workspace_id = :uid AND qh.conversation_id = ANY(CAST(:cids AS uuid[])) " "GROUP BY qh.conversation_id" ), {"uid": uid, "cids": cid_params}, @@ -91,7 +101,7 @@ async def get_conversations(user_id: str): text( "SELECT DISTINCT ON (qh.conversation_id) qh.conversation_id, qh.question " "FROM query_history qh " - "WHERE qh.user_id = :uid AND qh.conversation_id = ANY(CAST(:cids AS uuid[])) " + "WHERE qh.workspace_id = :uid AND qh.conversation_id = ANY(CAST(:cids AS uuid[])) " "ORDER BY qh.conversation_id, qh.timestamp DESC" ), {"uid": uid, "cids": cid_params}, @@ -102,7 +112,8 @@ async def get_conversations(user_id: str): for conv in convs: d = { "id": conv.id, - "user_id": conv.user_id, + "_id": conv.id, + "workspace_id": conv.workspace_id, "title": conv.title, "database_id": conv.database_id, "created_at": conv.created_at, @@ -115,16 +126,19 @@ async def get_conversations(user_id: str): return out -async def get_conversation(user_id: str, conversation_id: str): +async def get_conversation(workspace_id: str, user_id: str, conversation_id: str): try: - uid = _to_uuid(user_id) + uid = _to_uuid(workspace_id) + usr = _to_uuid(user_id) cid = _to_uuid(conversation_id) except (ValueError, TypeError): return None async with async_session() as session: result = await session.execute( select(Conversation).where( - Conversation.id == cid, Conversation.user_id == uid + Conversation.id == cid, + Conversation.workspace_id == uid, + Conversation.user_id == usr, ) ) conv = result.scalar_one_or_none() @@ -132,7 +146,8 @@ async def get_conversation(user_id: str, conversation_id: str): return None d = { "id": conv.id, - "user_id": conv.user_id, + "_id": conv.id, + "workspace_id": conv.workspace_id, "title": conv.title, "database_id": conv.database_id, "created_at": conv.created_at, @@ -144,7 +159,7 @@ async def get_conversation(user_id: str, conversation_id: str): select(QueryHistory) .where( QueryHistory.conversation_id == cid, - QueryHistory.user_id == uid, + QueryHistory.workspace_id == uid, ) .order_by(QueryHistory.timestamp.asc()) ) @@ -157,13 +172,15 @@ async def get_conversation(user_id: str, conversation_id: str): truncated = len(result) > MAX_RESTORED_ROWS td = { "id": turn.id, - "user_id": turn.user_id, + "_id": turn.id, + "workspace_id": turn.workspace_id, "question": turn.question, "sql": turn.sql, "result": result[:MAX_RESTORED_ROWS], "result_truncated": truncated, "confidence": turn.confidence, "restatement": turn.restatement, + "chart": turn.chart, "conversation_id": turn.conversation_id, "timestamp": turn.timestamp, } @@ -172,9 +189,12 @@ async def get_conversation(user_id: str, conversation_id: str): return d -async def rename_conversation(user_id: str, conversation_id: str, title: str) -> bool: +async def rename_conversation( + workspace_id: str, user_id: str, conversation_id: str, title: str +) -> bool: try: - uid = _to_uuid(user_id) + uid = _to_uuid(workspace_id) + usr = _to_uuid(user_id) cid = _to_uuid(conversation_id) except (ValueError, TypeError): return False @@ -182,7 +202,11 @@ async def rename_conversation(user_id: str, conversation_id: str, title: str) -> try: result = await session.execute( update(Conversation) - .where(Conversation.id == cid, Conversation.user_id == uid) + .where( + Conversation.id == cid, + Conversation.workspace_id == uid, + Conversation.user_id == usr, + ) .values(title=title, updated_at=_utcnow()) ) await session.commit() @@ -192,27 +216,30 @@ async def rename_conversation(user_id: str, conversation_id: str, title: str) -> raise -async def touch_conversation(conversation_id: str): +async def touch_conversation(conversation_id: str, user_id: str = None): try: cid = _to_uuid(conversation_id) + usr = _to_uuid(user_id) if user_id else None except (ValueError, TypeError): return async with async_session() as session: try: - await session.execute( - update(Conversation) - .where(Conversation.id == cid) - .values(updated_at=_utcnow()) - ) + stmt = update(Conversation).where(Conversation.id == cid) + if usr is not None: + stmt = stmt.where(Conversation.user_id == usr) + await session.execute(stmt.values(updated_at=_utcnow())) await session.commit() except Exception: await session.rollback() raise -async def delete_conversation(user_id: str, conversation_id: str) -> bool: +async def delete_conversation( + workspace_id: str, user_id: str, conversation_id: str +) -> bool: try: - uid = _to_uuid(user_id) + uid = _to_uuid(workspace_id) + usr = _to_uuid(user_id) cid = _to_uuid(conversation_id) except (ValueError, TypeError): return False @@ -220,7 +247,9 @@ async def delete_conversation(user_id: str, conversation_id: str) -> bool: async with session.begin(): owner = await session.execute( select(Conversation.id).where( - Conversation.id == cid, Conversation.user_id == uid + Conversation.id == cid, + Conversation.workspace_id == uid, + Conversation.user_id == usr, ) ) if owner.scalar_one_or_none() is None: @@ -228,32 +257,39 @@ async def delete_conversation(user_id: str, conversation_id: str) -> bool: await session.execute( delete(QueryHistory).where( QueryHistory.conversation_id == cid, - QueryHistory.user_id == uid, + QueryHistory.workspace_id == uid, ) ) await session.execute( delete(Conversation).where( - Conversation.id == cid, Conversation.user_id == uid + Conversation.id == cid, + Conversation.workspace_id == uid, + Conversation.user_id == usr, ) ) return True -async def clear_conversations(user_id: str): - uid = _to_uuid(user_id) +async def clear_conversations(workspace_id: str, user_id: str): + uid = _to_uuid(workspace_id) + usr = _to_uuid(user_id) async with async_session() as session: async with session.begin(): conv_ids_result = await session.execute( - select(Conversation.id).where(Conversation.user_id == uid) + select(Conversation.id).where( + Conversation.workspace_id == uid, Conversation.user_id == usr + ) ) conv_ids = [row[0] for row in conv_ids_result] if conv_ids: await session.execute( delete(QueryHistory).where( QueryHistory.conversation_id.in_(conv_ids), - QueryHistory.user_id == uid, + QueryHistory.workspace_id == uid, ) ) await session.execute( - delete(Conversation).where(Conversation.user_id == uid) + delete(Conversation).where( + Conversation.workspace_id == uid, Conversation.user_id == usr + ) ) diff --git a/backend/app/pgdatabase/dashboards.py b/backend/app/pgdatabase/dashboards.py new file mode 100644 index 00000000..b0763f0a --- /dev/null +++ b/backend/app/pgdatabase/dashboards.py @@ -0,0 +1,221 @@ +from sqlalchemy import select, delete, update +from sqlalchemy.orm import selectinload +from backend.app.pgdatabase.engine import async_session +from backend.app.models.dashboard import Dashboard, DashboardPanel +from backend.app.models.saved_query import SavedQuery +from backend.app.models.base import _utcnow +from backend.app.pgdatabase.serialization import _to_uuid, serialize_doc + + +async def create_dashboard( + workspace_id: str, created_by: str, name: str, description: str = None +): + uid = _to_uuid(workspace_id) + c_uid = _to_uuid(created_by) if created_by else None + + async with async_session() as session: + try: + d = Dashboard( + workspace_id=uid, created_by=c_uid, name=name, description=description + ) + session.add(d) + await session.commit() + await session.refresh(d) + return serialize_doc(d.__dict__) + except Exception: + await session.rollback() + raise + + +async def list_dashboards(workspace_id: str): + uid = _to_uuid(workspace_id) + async with async_session() as session: + result = await session.execute( + select(Dashboard) + .where(Dashboard.workspace_id == uid) + .order_by(Dashboard.updated_at.desc()) + ) + return [serialize_doc(r.__dict__) for r in result.scalars().all()] + + +async def get_dashboard(workspace_id: str, dashboard_id: str): + uid = _to_uuid(workspace_id) + did = _to_uuid(dashboard_id) + async with async_session() as session: + result = await session.execute( + select(Dashboard) + .options(selectinload(Dashboard.panels)) + .where(Dashboard.id == did, Dashboard.workspace_id == uid) + ) + d = result.scalar_one_or_none() + if not d: + return None + + doc = serialize_doc(d.__dict__) + doc["panels"] = [serialize_doc(p.__dict__) for p in d.panels] + return doc + + +async def update_dashboard(workspace_id: str, dashboard_id: str, **kwargs): + uid = _to_uuid(workspace_id) + did = _to_uuid(dashboard_id) + async with async_session() as session: + try: + kwargs["updated_at"] = _utcnow() + result = await session.execute( + update(Dashboard) + .where(Dashboard.id == did, Dashboard.workspace_id == uid) + .values(**kwargs) + ) + await session.commit() + return result.rowcount > 0 + except Exception: + await session.rollback() + raise + + +async def delete_dashboard(workspace_id: str, dashboard_id: str): + uid = _to_uuid(workspace_id) + did = _to_uuid(dashboard_id) + async with async_session() as session: + try: + result = await session.execute( + delete(Dashboard).where( + Dashboard.id == did, Dashboard.workspace_id == uid + ) + ) + await session.commit() + return result.rowcount > 0 + except Exception: + await session.rollback() + raise + + +async def add_panel(workspace_id: str, dashboard_id: str, **kwargs): + wid = _to_uuid(workspace_id) + did = _to_uuid(dashboard_id) + if "saved_query_id" in kwargs and kwargs["saved_query_id"]: + kwargs["saved_query_id"] = _to_uuid(kwargs["saved_query_id"]) + + async with async_session() as session: + try: + d = await session.execute( + select(Dashboard).where( + Dashboard.id == did, Dashboard.workspace_id == wid + ) + ) + if not d.scalar_one_or_none(): + raise ValueError("Dashboard not found in workspace") + + if kwargs.get("saved_query_id"): + sq = await session.execute( + select(SavedQuery.id).where( + SavedQuery.id == kwargs["saved_query_id"], + SavedQuery.workspace_id == wid, + ) + ) + if not sq.scalar_one_or_none(): + raise ValueError("Saved query not found in workspace") + + panel = DashboardPanel(dashboard_id=did, **kwargs) + session.add(panel) + await session.commit() + await session.refresh(panel) + return serialize_doc(panel.__dict__) + except Exception: + await session.rollback() + raise + + +async def update_panel(workspace_id: str, dashboard_id: str, panel_id: str, **kwargs): + wid = _to_uuid(workspace_id) + did = _to_uuid(dashboard_id) + pid = _to_uuid(panel_id) + + if "saved_query_id" in kwargs and kwargs["saved_query_id"]: + kwargs["saved_query_id"] = _to_uuid(kwargs["saved_query_id"]) + + async with async_session() as session: + try: + d = await session.execute( + select(Dashboard).where( + Dashboard.id == did, Dashboard.workspace_id == wid + ) + ) + if not d.scalar_one_or_none(): + raise ValueError("Dashboard not found in workspace") + + if kwargs.get("saved_query_id"): + sq = await session.execute( + select(SavedQuery.id).where( + SavedQuery.id == kwargs["saved_query_id"], + SavedQuery.workspace_id == wid, + ) + ) + if not sq.scalar_one_or_none(): + raise ValueError("Saved query not found in workspace") + + kwargs["updated_at"] = _utcnow() + result = await session.execute( + update(DashboardPanel) + .where(DashboardPanel.id == pid, DashboardPanel.dashboard_id == did) + .values(**kwargs) + ) + await session.commit() + return result.rowcount > 0 + except Exception: + await session.rollback() + raise + + +async def delete_panel(workspace_id: str, dashboard_id: str, panel_id: str): + wid = _to_uuid(workspace_id) + did = _to_uuid(dashboard_id) + pid = _to_uuid(panel_id) + async with async_session() as session: + try: + d = await session.execute( + select(Dashboard).where( + Dashboard.id == did, Dashboard.workspace_id == wid + ) + ) + if not d.scalar_one_or_none(): + return False + + result = await session.execute( + delete(DashboardPanel).where( + DashboardPanel.id == pid, DashboardPanel.dashboard_id == did + ) + ) + await session.commit() + return result.rowcount > 0 + except Exception: + await session.rollback() + raise + + +async def update_panels_batch(workspace_id: str, dashboard_id: str, updates: list): + wid = _to_uuid(workspace_id) + did = _to_uuid(dashboard_id) + async with async_session() as session: + try: + d = await session.execute( + select(Dashboard).where( + Dashboard.id == did, Dashboard.workspace_id == wid + ) + ) + if not d.scalar_one_or_none(): + return False + + for u in updates: + pid = _to_uuid(u["id"]) + await session.execute( + update(DashboardPanel) + .where(DashboardPanel.id == pid, DashboardPanel.dashboard_id == did) + .values(position=u["position"], updated_at=_utcnow()) + ) + await session.commit() + return True + except Exception: + await session.rollback() + raise diff --git a/backend/app/pgdatabase/history.py b/backend/app/pgdatabase/history.py index a86727ff..94da6653 100644 --- a/backend/app/pgdatabase/history.py +++ b/backend/app/pgdatabase/history.py @@ -3,24 +3,35 @@ from sqlalchemy import select, delete, func, text from backend.app.pgdatabase.engine import async_session -from backend.app.pgdatabase.models import QueryHistory +from backend.app.models.conversation import QueryHistory from backend.app.pgdatabase.serialization import _to_uuid, serialize_doc async def save_query( - user_id, question, sql, result, confidence, conversation_id=None, restatement="" + workspace_id, + question, + sql, + result, + confidence, + conversation_id=None, + restatement="", + user_id=None, + chart=None, ): - uid = _to_uuid(user_id) + wid = _to_uuid(workspace_id) + uid = _to_uuid(user_id) if user_id else None conv_id = _to_uuid(conversation_id) if conversation_id else None async with async_session() as session: try: qh = QueryHistory( + workspace_id=wid, user_id=uid, question=question, sql=sql, result=result, confidence=confidence, restatement=restatement, + chart=chart, conversation_id=conv_id, ) session.add(qh) @@ -30,12 +41,12 @@ async def save_query( raise -async def get_query_history(user_id: str, limit: int = 100): - uid = _to_uuid(user_id) +async def get_query_history(workspace_id: str, limit: int = 100): + uid = _to_uuid(workspace_id) async with async_session() as session: result = await session.execute( select(QueryHistory) - .where(QueryHistory.user_id == uid) + .where(QueryHistory.workspace_id == uid) .order_by(QueryHistory.timestamp.desc()) .limit(limit) ) @@ -44,6 +55,7 @@ async def get_query_history(user_id: str, limit: int = 100): for row in rows: d = { "id": row.id, + "workspace_id": row.workspace_id, "user_id": row.user_id, "question": row.question, "sql": row.sql, @@ -57,18 +69,18 @@ async def get_query_history(user_id: str, limit: int = 100): return out -async def get_query_stats(user_id: str): - uid = _to_uuid(user_id) +async def get_query_stats(workspace_id: str): + uid = _to_uuid(workspace_id) async with async_session() as session: total_q = await session.scalar( - select(func.count()).where(QueryHistory.user_id == uid) + select(func.count()).where(QueryHistory.workspace_id == uid) ) total = total_q or 0 conf_rows = await session.execute( text( "SELECT LOWER(COALESCE(confidence, 'low')) AS level, COUNT(*) AS count " - "FROM query_history WHERE user_id = :uid GROUP BY LOWER(COALESCE(confidence, 'low'))" + "FROM query_history WHERE workspace_id = :uid GROUP BY LOWER(COALESCE(confidence, 'low'))" ).bindparams(uid=uid) ) confidence_counts = {"High": 0, "Medium": 0, "Low": 0} @@ -79,7 +91,7 @@ async def get_query_stats(user_id: str): day_rows = await session.execute( text( "SELECT DATE(timestamp) AS date, COUNT(*) AS count " - "FROM query_history WHERE user_id = :uid " + "FROM query_history WHERE workspace_id = :uid " "GROUP BY DATE(timestamp) ORDER BY date DESC LIMIT 90" ).bindparams(uid=uid) ) @@ -88,7 +100,7 @@ async def get_query_stats(user_id: str): sql_rows = await session.execute( text( - "SELECT sql FROM query_history WHERE user_id = :uid " + "SELECT sql FROM query_history WHERE workspace_id = :uid " "ORDER BY timestamp DESC LIMIT 200" ).bindparams(uid=uid) ) @@ -140,9 +152,9 @@ async def get_query_stats(user_id: str): } -async def delete_history_entry(user_id: str, entry_id: str) -> bool: +async def delete_history_entry(workspace_id: str, entry_id: str) -> bool: try: - uid = _to_uuid(user_id) + uid = _to_uuid(workspace_id) eid = _to_uuid(entry_id) except (ValueError, TypeError): return False @@ -150,7 +162,7 @@ async def delete_history_entry(user_id: str, entry_id: str) -> bool: try: result = await session.execute( delete(QueryHistory).where( - QueryHistory.id == eid, QueryHistory.user_id == uid + QueryHistory.id == eid, QueryHistory.workspace_id == uid ) ) await session.commit() @@ -160,12 +172,12 @@ async def delete_history_entry(user_id: str, entry_id: str) -> bool: raise -async def clear_history(user_id: str): - uid = _to_uuid(user_id) +async def clear_history(workspace_id: str): + uid = _to_uuid(workspace_id) async with async_session() as session: try: await session.execute( - delete(QueryHistory).where(QueryHistory.user_id == uid) + delete(QueryHistory).where(QueryHistory.workspace_id == uid) ) await session.commit() except Exception: diff --git a/backend/app/pgdatabase/knowledge.py b/backend/app/pgdatabase/knowledge.py index 874131ac..895c7b6b 100644 --- a/backend/app/pgdatabase/knowledge.py +++ b/backend/app/pgdatabase/knowledge.py @@ -8,7 +8,7 @@ from sqlalchemy import select, delete, func from sqlalchemy.exc import IntegrityError -from backend.app.pgdatabase.models import ( +from backend.app.models.catalog import ( VerifiedQA, Glossary, CatalogColumn, @@ -56,12 +56,12 @@ def __init__(self, session_factory): """ self._session_factory = session_factory - async def add_verified(self, user_id, db_id, question, sql, restatement=""): + async def add_verified(self, workspace_id, db_id, question, sql, restatement=""): """ Add a verified question-and-answer entry unless a sufficiently similar question already exists. Parameters: - user_id: Identifier of the user who owns the entry. + workspace_id: Identifier of the user who owns the entry. db_id: Identifier of the database associated with the entry. question: Natural-language question associated with the SQL query. sql: SQL query that answers the question. @@ -71,7 +71,7 @@ async def add_verified(self, user_id, db_id, question, sql, restatement=""): async with self._session_factory() as session: result = await session.execute( select(VerifiedQA).where( - VerifiedQA.user_id == user_id, VerifiedQA.db_id == db_id + VerifiedQA.workspace_id == workspace_id, VerifiedQA.db_id == db_id ) ) tb = _tokens(question) @@ -81,7 +81,7 @@ async def add_verified(self, user_id, db_id, question, sql, restatement=""): return session.add( VerifiedQA( - user_id=user_id, + workspace_id=workspace_id, db_id=db_id, question=question, sql=sql, @@ -93,18 +93,18 @@ async def add_verified(self, user_id, db_id, question, sql, restatement=""): except IntegrityError: await session.rollback() logger.warning( - "Duplicate verified QA skipped (user=%s db=%s question=%s)", - user_id, + "Duplicate verified QA skipped (workspace=%s db=%s question=%s)", + workspace_id, db_id, question, ) - async def get_verified(self, user_id, db_id): + async def get_verified(self, workspace_id, db_id): """ Retrieve verified question-and-answer entries for a user and database, with newest entries first. Parameters: - user_id: Identifier of the user. + workspace_id: Identifier of the user. db_id: Identifier of the database. Returns: @@ -113,7 +113,9 @@ async def get_verified(self, user_id, db_id): async with self._session_factory() as session: result = await session.execute( select(VerifiedQA) - .where(VerifiedQA.user_id == user_id, VerifiedQA.db_id == db_id) + .where( + VerifiedQA.workspace_id == workspace_id, VerifiedQA.db_id == db_id + ) .order_by(VerifiedQA.created_at.desc()) ) return [ @@ -121,12 +123,12 @@ async def get_verified(self, user_id, db_id): for r in result.scalars().all() ] - async def count_verified(self, user_id, db_id): + async def count_verified(self, workspace_id, db_id): """ Count verified Q&A entries for a user and database. Parameters: - user_id: Identifier of the user. + workspace_id: Identifier of the user. db_id: Identifier of the database. Returns: @@ -135,12 +137,14 @@ async def count_verified(self, user_id, db_id): async with self._session_factory() as session: result = await session.execute( select(func.count()).where( - VerifiedQA.user_id == user_id, VerifiedQA.db_id == db_id + VerifiedQA.workspace_id == workspace_id, VerifiedQA.db_id == db_id ) ) return result.scalar() or 0 - async def retrieve_similar(self, user_id, db_id, question, k=3, threshold=0.25): + async def retrieve_similar( + self, workspace_id, db_id, question, k=3, threshold=0.25 + ): """ Finds verified questions that are similar to the provided question. @@ -155,14 +159,14 @@ async def retrieve_similar(self, user_id, db_id, question, k=3, threshold=0.25): scored = [] tb = _tokens(question) b_lower = question.lower() - for c in await self.get_verified(user_id, db_id): + for c in await self.get_verified(workspace_id, db_id): s = _similarity(c["question"], question, tb, b_lower) if s >= threshold: scored.append({**c, "similarity": round(s, 3)}) scored.sort(key=lambda x: -x["similarity"]) return scored[:k] - async def set_glossary(self, user_id, db_id, terms): + async def set_glossary(self, workspace_id, db_id, terms): """ Replace the glossary entries for a user and database. @@ -172,13 +176,13 @@ async def set_glossary(self, user_id, db_id, terms): async with self._session_factory() as session: await session.execute( delete(Glossary).where( - Glossary.user_id == user_id, Glossary.db_id == db_id + Glossary.workspace_id == workspace_id, Glossary.db_id == db_id ) ) for t in terms: session.add( Glossary( - user_id=user_id, + workspace_id=workspace_id, db_id=db_id, term=t.get("term", ""), maps_to=t.get("maps_to", ""), @@ -187,11 +191,11 @@ async def set_glossary(self, user_id, db_id, terms): ) await session.commit() - async def get_glossary(self, user_id, db_id): + async def get_glossary(self, workspace_id, db_id): """Retrieve glossary entries for a user and database. Parameters: - user_id: The user identifier. + workspace_id: The user identifier. db_id: The database identifier. Returns: @@ -200,7 +204,7 @@ async def get_glossary(self, user_id, db_id): async with self._session_factory() as session: result = await session.execute( select(Glossary).where( - Glossary.user_id == user_id, Glossary.db_id == db_id + Glossary.workspace_id == workspace_id, Glossary.db_id == db_id ) ) return [ @@ -236,7 +240,7 @@ async def get_glossary(self, user_id, db_id): ), } - async def set_catalog(self, user_id, db_id, catalog): + async def set_catalog(self, workspace_id, db_id, catalog): """ Replace the specified catalog sections for a user and database. @@ -249,22 +253,23 @@ async def set_catalog(self, user_id, db_id, catalog): continue await session.execute( delete(model_class).where( - model_class.user_id == user_id, model_class.db_id == db_id + model_class.workspace_id == workspace_id, + model_class.db_id == db_id, ) ) for entry in catalog[key] or []: - kwargs = {"user_id": user_id, "db_id": db_id} + kwargs = {"workspace_id": workspace_id, "db_id": db_id} for c, ok in zip(cols, in_keys): kwargs[c] = str(entry.get(ok, "") or "") session.add(model_class(**kwargs)) await session.commit() - async def get_catalog(self, user_id, db_id): + async def get_catalog(self, workspace_id, db_id): """ Retrieve the semantic catalog entries for a user and database. Parameters: - user_id: Identifier of the user whose catalog entries are retrieved. + workspace_id: Identifier of the user whose catalog entries are retrieved. db_id: Identifier of the database whose catalog entries are retrieved. Returns: @@ -275,7 +280,8 @@ async def get_catalog(self, user_id, db_id): for key, (model_class, cols, out_keys) in self._CATALOG_CLASSES.items(): result = await session.execute( select(model_class).where( - model_class.user_id == user_id, model_class.db_id == db_id + model_class.workspace_id == workspace_id, + model_class.db_id == db_id, ) ) rows = [] @@ -287,20 +293,20 @@ async def get_catalog(self, user_id, db_id): out[key] = rows return out - async def catalog_is_empty(self, user_id, db_id): + async def catalog_is_empty(self, workspace_id, db_id): """Determine whether a user's catalog contains any entries. Parameters: - user_id: The user whose catalog is checked. + workspace_id: The user whose catalog is checked. db_id: The database whose catalog is checked. Returns: bool: `True` if all catalog sections are empty, `False` otherwise. """ - catalog = await self.get_catalog(user_id, db_id) + catalog = await self.get_catalog(workspace_id, db_id) return not any(catalog.values()) - async def seed_sample(self, user_id, db_id): + async def seed_sample(self, workspace_id, db_id): """Seed sample knowledge for a new sample-database connection. All operations run in one transaction so partial failures roll back @@ -309,70 +315,84 @@ async def seed_sample(self, user_id, db_id): async with self._session_factory() as session: count = await session.execute( select(func.count()).where( - VerifiedQA.user_id == user_id, VerifiedQA.db_id == db_id + VerifiedQA.workspace_id == workspace_id, VerifiedQA.db_id == db_id ) ) if count.scalar(): return await session.execute( delete(Glossary).where( - Glossary.user_id == user_id, Glossary.db_id == db_id + Glossary.workspace_id == workspace_id, Glossary.db_id == db_id ) ) for t in [ { "term": "Revenue", - "maps_to": "orders.total_amount", - "sql_hint": "Sum of total_amount on orders with status = completed", + "maps_to": "orders.total", + "sql_hint": "Sum of orders.total, which already includes every order position", }, { "term": "Active customer", - "maps_to": "orders.created_at", + "maps_to": "orders.ordertimestamp", "sql_hint": "A customer with at least one order in the last 90 days", }, + { + "term": "Article", + "maps_to": "articles.productid", + "sql_hint": "One product in a specific size and colour — products group articles", + }, { "term": "Top product", - "maps_to": "order_items.quantity", - "sql_hint": "Product ranked by units sold (sum of quantity)", + "maps_to": "order_positions.amount", + "sql_hint": "Product ranked by units sold: sum of order_positions.amount, joined through articles", }, ]: - session.add(Glossary(user_id=user_id, db_id=db_id, **t)) + session.add(Glossary(workspace_id=workspace_id, db_id=db_id, **t)) for q, s, r in [ ( - "How many orders were completed last month?", - "SELECT COUNT(*) AS completed_orders\nFROM orders\nWHERE status = 'completed'\n AND created_at >= date('now','start of month','-1 month')\n AND created_at < date('now','start of month');", - "Count of orders with status 'completed' created in the previous calendar month", + "How many orders were placed last month?", + "SELECT COUNT(*) AS orders_placed\nFROM orders\nWHERE ordertimestamp >= date('now','start of month','-1 month')\n AND ordertimestamp < date('now','start of month');", + "Count of orders placed in the previous calendar month", ), ( "Which product category brings in the most revenue?", - "SELECT p.category, ROUND(SUM(oi.quantity*oi.unit_price)) AS revenue\nFROM order_items oi\nJOIN products p ON p.id = oi.product_id\nJOIN orders o ON o.id = oi.order_id\nWHERE o.status = 'completed'\nGROUP BY p.category\nORDER BY revenue DESC;", + "SELECT p.category, ROUND(SUM(op.amount * op.price), 2) AS revenue\nFROM order_positions op\nJOIN articles a ON a.id = op.articleid\nJOIN products p ON p.id = a.productid\nGROUP BY p.category\nORDER BY revenue DESC;", "Total revenue per product category, highest first", ), ( - "How many customers do we have in each segment?", - "SELECT segment, COUNT(*) AS customers\nFROM customers\nGROUP BY segment\nORDER BY customers DESC;", - "Count of customers grouped by segment", + "Who are our top 10 customers by total spend?", + "SELECT c.firstname || ' ' || c.lastname AS customer, ROUND(SUM(o.total), 2) AS total_spend\nFROM orders o\nJOIN customers c ON c.id = o.customerid\nGROUP BY c.id\nORDER BY total_spend DESC\nLIMIT 10;", + "The ten customers with the highest sum of order totals", + ), + ( + "Which articles are out of stock?", + "SELECT a.id, a.description, s.count AS in_stock\nFROM articles a\nJOIN stock s ON s.articleid = a.id\nWHERE s.count = 0\n AND a.currentlyactive = 1;", + "Active articles whose stock count has reached zero", ), ]: session.add( VerifiedQA( - user_id=user_id, db_id=db_id, question=q, sql=s, restatement=r + workspace_id=workspace_id, + db_id=db_id, + question=q, + sql=s, + restatement=r, ) ) await session.commit() - async def trust_level(self, user_id, db_id): + async def trust_level(self, workspace_id, db_id): """ Summarize the trust level for a user's database from its verified answers. Parameters: - user_id: Identifier of the user. + workspace_id: Identifier of the user. db_id: Identifier of the database. Returns: A dictionary containing the trust `level`, verified-answer count, percentage, and explanatory note. """ - n = await self.count_verified(user_id, db_id) + n = await self.count_verified(workspace_id, db_id) if n >= 7: return { "level": "Trusted", diff --git a/backend/app/pgdatabase/models.py b/backend/app/pgdatabase/models.py deleted file mode 100644 index 49c3da6f..00000000 --- a/backend/app/pgdatabase/models.py +++ /dev/null @@ -1,275 +0,0 @@ -"""SQLAlchemy ORM models for application state.""" - -import os -import time -import uuid -from datetime import datetime, timezone -from typing import Optional - -from sqlalchemy import ( - Boolean, - String, - Integer, - UniqueConstraint, - ForeignKey, - Text, - DateTime, - Index, -) -from sqlalchemy.dialects.postgresql import JSONB, UUID as PgUUID -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column - - -def _utcnow(): - return datetime.now(timezone.utc) - - -def _uuid7(): - try: - return uuid.uuid7() - except AttributeError: - pass - ts = int(time.time() * 1000) - rand = int.from_bytes(os.urandom(10), "big") - rand_a = rand >> 68 - rand_b = rand & ((1 << 62) - 1) - return uuid.UUID( - int=(ts << 80) | (0x7 << 76) | (rand_a << 64) | (0x2 << 62) | rand_b - ) - - -class Base(DeclarativeBase): - pass - - -class User(Base): - __tablename__ = "users" - id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), primary_key=True, default=_uuid7 - ) - email: Mapped[str] = mapped_column(String, unique=True, nullable=False) - hashed_pass: Mapped[str] = mapped_column(String, nullable=False, default="") - role: Mapped[str] = mapped_column(String, nullable=False, default="user") - google_id: Mapped[Optional[str]] = mapped_column(String, unique=True, nullable=True) - supabase_id: Mapped[Optional[str]] = mapped_column( - String, unique=True, nullable=True - ) - email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - tour_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=_utcnow - ) - - -class Conversation(Base): - __tablename__ = "conversations" - id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), primary_key=True, default=_uuid7 - ) - user_id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - title: Mapped[str] = mapped_column(String, nullable=False, default="") - database_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=_utcnow - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=_utcnow - ) - - -class QueryHistory(Base): - __tablename__ = "query_history" - id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), primary_key=True, default=_uuid7 - ) - user_id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - question: Mapped[str] = mapped_column(Text, nullable=False) - sql: Mapped[str] = mapped_column(Text, nullable=False, default="") - result: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) - confidence: Mapped[str] = mapped_column(String, nullable=False, default="low") - restatement: Mapped[str] = mapped_column(Text, nullable=False, default="") - conversation_id: Mapped[Optional[uuid.UUID]] = mapped_column( - PgUUID(as_uuid=True), - ForeignKey("conversations.id", ondelete="SET NULL"), - nullable=True, - ) - timestamp: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=_utcnow - ) - - -class RecentConnection(Base): - __tablename__ = "recent_connections" - id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), primary_key=True, default=_uuid7 - ) - user_id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - db_url: Mapped[str] = mapped_column(Text, nullable=False) - display_url: Mapped[str] = mapped_column(String, nullable=False) - dialect: Mapped[str] = mapped_column(String, nullable=False) - db_id: Mapped[str] = mapped_column(String, nullable=False) - table_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - connected_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=_utcnow - ) - __table_args__ = (UniqueConstraint("user_id", "db_id"),) - - -class VerifiedQA(Base): - __tablename__ = "verified_qas" - id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), primary_key=True, default=_uuid7 - ) - user_id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - db_id: Mapped[str] = mapped_column(String, nullable=False) - question: Mapped[str] = mapped_column(Text, nullable=False) - sql: Mapped[str] = mapped_column(Text, nullable=False, default="") - restatement: Mapped[str] = mapped_column(Text, nullable=False, default="") - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=_utcnow - ) - __table_args__ = ( - UniqueConstraint( - "user_id", "db_id", "question", name="uq_verified_qa_user_db_question" - ), - Index("ix_verified_qa_user_db", "user_id", "db_id"), - ) - - -class Glossary(Base): - __tablename__ = "glossary_terms" - id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), primary_key=True, default=_uuid7 - ) - user_id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - db_id: Mapped[str] = mapped_column(String, nullable=False) - term: Mapped[str] = mapped_column(String, nullable=False) - maps_to: Mapped[str] = mapped_column(String, nullable=False) - sql_hint: Mapped[str] = mapped_column(String, nullable=False, default="") - __table_args__ = (Index("ix_glossary_user_db", "user_id", "db_id"),) - - -class CatalogColumn(Base): - __tablename__ = "catalog_columns" - id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), primary_key=True, default=_uuid7 - ) - user_id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - db_id: Mapped[str] = mapped_column(String, nullable=False) - table_name: Mapped[str] = mapped_column(String, nullable=False) - column_name: Mapped[str] = mapped_column(String, nullable=False) - description: Mapped[str] = mapped_column(Text, nullable=False) - __table_args__ = (Index("ix_cat_col_user_db", "user_id", "db_id"),) - - -class CatalogMetric(Base): - __tablename__ = "catalog_metrics" - id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), primary_key=True, default=_uuid7 - ) - user_id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - db_id: Mapped[str] = mapped_column(String, nullable=False) - name: Mapped[str] = mapped_column(String, nullable=False) - description: Mapped[str] = mapped_column(Text, nullable=False, default="") - sql_expression: Mapped[str] = mapped_column(Text, nullable=False) - __table_args__ = (Index("ix_cat_met_user_db", "user_id", "db_id"),) - - -class CatalogJoin(Base): - __tablename__ = "catalog_joins" - id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), primary_key=True, default=_uuid7 - ) - user_id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - db_id: Mapped[str] = mapped_column(String, nullable=False) - tables: Mapped[str] = mapped_column(String, nullable=False) - join_condition: Mapped[str] = mapped_column(Text, nullable=False) - description: Mapped[str] = mapped_column(Text, nullable=False, default="") - __table_args__ = (Index("ix_cat_join_user_db", "user_id", "db_id"),) - - -class CatalogSynonym(Base): - __tablename__ = "catalog_synonyms" - id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), primary_key=True, default=_uuid7 - ) - user_id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - db_id: Mapped[str] = mapped_column(String, nullable=False) - term: Mapped[str] = mapped_column(String, nullable=False) - entity_type: Mapped[str] = mapped_column(String, nullable=False) - entity_name: Mapped[str] = mapped_column(String, nullable=False) - __table_args__ = (Index("ix_cat_syn_user_db", "user_id", "db_id"),) - - -class CatalogValueMapping(Base): - __tablename__ = "catalog_value_mappings" - id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), primary_key=True, default=_uuid7 - ) - user_id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - db_id: Mapped[str] = mapped_column(String, nullable=False) - table_name: Mapped[str] = mapped_column(String, nullable=False) - column_name: Mapped[str] = mapped_column(String, nullable=False) - db_value: Mapped[str] = mapped_column(String, nullable=False) - business_label: Mapped[str] = mapped_column(String, nullable=False) - __table_args__ = (Index("ix_cat_val_user_db", "user_id", "db_id"),) - - -class PasswordResetToken(Base): - __tablename__ = "password_reset_tokens" - id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), primary_key=True, default=_uuid7 - ) - user_id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - jti_hash: Mapped[str] = mapped_column(String, nullable=False, unique=True) - expires_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False - ) - consumed: Mapped[bool] = mapped_column(nullable=False, default=False) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=_utcnow - ) - - -class OtpCode(Base): - __tablename__ = "otp_codes" - id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), primary_key=True, default=_uuid7 - ) - user_id: Mapped[uuid.UUID] = mapped_column( - PgUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - code_hash: Mapped[str] = mapped_column(String, nullable=False) - purpose: Mapped[str] = mapped_column(String, nullable=False) - expires_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False - ) - used: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=_utcnow - ) - __table_args__ = ( - UniqueConstraint("user_id", "purpose", name="uq_otp_user_purpose"), - ) diff --git a/backend/app/pgdatabase/otp.py b/backend/app/pgdatabase/otp.py index 314e8c57..5aac0016 100644 --- a/backend/app/pgdatabase/otp.py +++ b/backend/app/pgdatabase/otp.py @@ -8,7 +8,7 @@ from sqlalchemy import delete as sqlalchemy_delete, select, update as sqlalchemy_update from backend.app.pgdatabase.engine import get_engine -from backend.app.pgdatabase.models import OtpCode +from backend.app.models.auth_token import OtpCode logger = logging.getLogger(__name__) diff --git a/backend/app/pgdatabase/saved_queries.py b/backend/app/pgdatabase/saved_queries.py new file mode 100644 index 00000000..90404c1d --- /dev/null +++ b/backend/app/pgdatabase/saved_queries.py @@ -0,0 +1,84 @@ +from sqlalchemy import select, delete, update +from backend.app.pgdatabase.engine import async_session +from backend.app.models.saved_query import SavedQuery +from backend.app.models.base import _utcnow +from backend.app.pgdatabase.serialization import _to_uuid, serialize_doc + + +async def create_saved_query(workspace_id: str, created_by: str, **kwargs): + uid = _to_uuid(workspace_id) + c_uid = _to_uuid(created_by) if created_by else None + + async with async_session() as session: + try: + sq = SavedQuery(workspace_id=uid, created_by=c_uid, **kwargs) + session.add(sq) + await session.commit() + await session.refresh(sq) + return serialize_doc(sq.__dict__) + except Exception: + await session.rollback() + raise + + +async def list_saved_queries(workspace_id: str, limit: int = 50, offset: int = 0): + uid = _to_uuid(workspace_id) + async with async_session() as session: + result = await session.execute( + select(SavedQuery) + .where(SavedQuery.workspace_id == uid) + .order_by(SavedQuery.updated_at.desc()) + .limit(limit) + .offset(offset) + ) + return [serialize_doc(r.__dict__) for r in result.scalars().all()] + + +async def get_saved_query(workspace_id: str, query_id: str): + uid = _to_uuid(workspace_id) + qid = _to_uuid(query_id) + async with async_session() as session: + result = await session.execute( + select(SavedQuery).where( + SavedQuery.id == qid, SavedQuery.workspace_id == uid + ) + ) + sq = result.scalar_one_or_none() + if sq: + return serialize_doc(sq.__dict__) + return None + + +async def update_saved_query(workspace_id: str, query_id: str, **kwargs): + uid = _to_uuid(workspace_id) + qid = _to_uuid(query_id) + async with async_session() as session: + try: + kwargs["updated_at"] = _utcnow() + result = await session.execute( + update(SavedQuery) + .where(SavedQuery.id == qid, SavedQuery.workspace_id == uid) + .values(**kwargs) + ) + await session.commit() + return result.rowcount > 0 + except Exception: + await session.rollback() + raise + + +async def delete_saved_query(workspace_id: str, query_id: str): + uid = _to_uuid(workspace_id) + qid = _to_uuid(query_id) + async with async_session() as session: + try: + result = await session.execute( + delete(SavedQuery).where( + SavedQuery.id == qid, SavedQuery.workspace_id == uid + ) + ) + await session.commit() + return result.rowcount > 0 + except Exception: + await session.rollback() + raise diff --git a/backend/app/pgdatabase/serialization.py b/backend/app/pgdatabase/serialization.py index 2bb5e399..39c3e593 100644 --- a/backend/app/pgdatabase/serialization.py +++ b/backend/app/pgdatabase/serialization.py @@ -3,6 +3,19 @@ import uuid from datetime import datetime +_UUID_KEYS = ( + "id", + "user_id", + "workspace_id", + "conversation_id", + "dashboard_id", + "saved_query_id", + "database_id", + "created_by", + "invited_by", + "actor_id", +) + def _to_uuid(val: str | uuid.UUID) -> uuid.UUID: if isinstance(val, uuid.UUID): @@ -13,13 +26,16 @@ def _to_uuid(val: str | uuid.UUID) -> uuid.UUID: def serialize_doc(doc): if doc is None: return None - out = doc.copy() - out["_id"] = str(out.pop("id", "")) - if "user_id" in out and out["user_id"] is not None: - out["user_id"] = str(out["user_id"]) - if "conversation_id" in out and out["conversation_id"] is not None: - out["conversation_id"] = str(out["conversation_id"]) - for key in ("created_at", "updated_at", "timestamp", "connected_at"): + out = {k: v for k, v in doc.items() if not k.startswith("_")} + raw_id = out.pop("id", None) + if raw_id is not None: + sid = str(raw_id) + out["_id"] = sid + out["id"] = sid # dual keys for API/frontend compatibility + for key in _UUID_KEYS: + if key in out and out[key] is not None and key != "id": + out[key] = str(out[key]) + for key in ("created_at", "updated_at", "timestamp", "connected_at", "joined_at"): if key in out and isinstance(out[key], datetime): out[key] = out[key].isoformat() return out diff --git a/backend/app/pgdatabase/users.py b/backend/app/pgdatabase/users.py index 85c09b2a..fa879a3d 100644 --- a/backend/app/pgdatabase/users.py +++ b/backend/app/pgdatabase/users.py @@ -8,7 +8,7 @@ from backend.app.models.user import UserInDB from backend.app.pgdatabase.engine import async_session -from backend.app.pgdatabase.models import User +from backend.app.models.orm_user import User from backend.app.pgdatabase.serialization import _to_uuid, serialize_doc logger = logging.getLogger(__name__) @@ -40,6 +40,10 @@ def _user_to_dict(user) -> dict: "email_verified": user.email_verified, "tour_completed": user.tour_completed, "created_at": user.created_at, + "first_name": user.first_name, + "last_name": user.last_name, + "avatar_url": user.avatar_url, + "metadata": user.metadata_, } ) @@ -116,6 +120,10 @@ async def get_user_by_id(user_id: str) -> Optional[dict]: "email_verified", "email", "tour_completed", + "first_name", + "last_name", + "avatar_url", + "metadata", } ) @@ -125,6 +133,10 @@ async def update_user(user_id: str, **fields): if unexpected: logger.warning("Blocked update of disallowed user fields: %s", unexpected) return False + + if "metadata" in fields: + fields["metadata_"] = fields.pop("metadata") + try: uid = _to_uuid(user_id) except (ValueError, TypeError): diff --git a/backend/app/repair.py b/backend/app/repair.py index 3808d51e..0d62bb74 100644 --- a/backend/app/repair.py +++ b/backend/app/repair.py @@ -85,6 +85,7 @@ async def run_repair_loop( "ok": bool, # True if a candidate passed all checks "sql": str, # the accepted SQL, or the last attempt on failure "restatement": str, + "chart": dict | None, # the generator's chart choice, if it made one "result": dict | None, # execute() output when ok and an executor was given "attempts": [ {"sql", "stage", "errors"?}, ... ], } @@ -130,6 +131,7 @@ async def run_repair_loop( "sql": sql, "restatement": last.get("restatement", ""), "assumptions": last.get("assumptions", []), + "chart": last.get("chart"), "result": result, "attempts": attempts, } @@ -140,6 +142,7 @@ async def run_repair_loop( "sql": sql, "restatement": last.get("restatement", ""), "assumptions": last.get("assumptions", []), + "chart": last.get("chart"), "result": None, "attempts": attempts, } @@ -149,6 +152,7 @@ async def run_repair_loop( "sql": (last.get("sql") or "").strip(), "restatement": last.get("restatement", ""), "assumptions": last.get("assumptions", []), + "chart": last.get("chart"), "result": None, "attempts": attempts, } diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index a46e707b..9622e373 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -81,6 +81,16 @@ async def me(user_token=Depends(get_current_user)): return JSONResponse({"message": "My details", "content": user}) +@router.patch("/me") +async def update_me( + req: backend.app.models.user.UpdateProfile, user_token=Depends(get_current_user) +): + user = await backend.app.controllers.auth.update_profile(user_token["user_id"], req) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return JSONResponse({"message": "Profile updated", "content": user}) + + @router.post("/login") @limiter.limit("10/minute") async def login(request: Request, login_data: UserLogin): diff --git a/backend/app/routes/catalog.py b/backend/app/routes/catalog.py index c15ed6d3..0c55d73e 100644 --- a/backend/app/routes/catalog.py +++ b/backend/app/routes/catalog.py @@ -2,7 +2,13 @@ from fastapi import APIRouter, Depends, Request, HTTPException -from backend.app.dependencies import get_current_user, get_db, get_kb, get_providers +from backend.app.dependencies import ( + require_permission, + get_current_db_id, + get_db, + get_kb, + get_providers, +) from backend.app.models.api import CatalogPayload from backend.app.ratelimit import limiter import backend.app.controllers.catalog as ctrl @@ -13,13 +19,14 @@ @router.get("/api/catalog") async def get_catalog( - user_token=Depends(get_current_user), + workspace=Depends(require_permission("catalog.view")), db=Depends(get_db), kb=Depends(get_kb), + db_id=Depends(get_current_db_id), ): """Return the saved semantic catalog for the connected database.""" try: - return await ctrl.get_catalog(user_token["user_id"], db, kb) + return await ctrl.get_catalog(workspace["workspace_id"], db, kb, db_id) except HTTPException: raise except Exception: @@ -30,13 +37,16 @@ async def get_catalog( @router.post("/api/catalog") async def save_catalog( payload: CatalogPayload, - user_token=Depends(get_current_user), + workspace=Depends(require_permission("catalog.manage")), db=Depends(get_db), kb=Depends(get_kb), + db_id=Depends(get_current_db_id), ): """Replace the connected database's semantic catalog with ``payload``.""" try: - return await ctrl.save_catalog(user_token["user_id"], db, kb, payload) + return await ctrl.save_catalog( + workspace["workspace_id"], db, kb, payload, db_id + ) except HTTPException: raise except Exception: @@ -48,13 +58,14 @@ async def save_catalog( @limiter.limit("10/minute") async def suggest_catalog( request: Request, - user_token=Depends(get_current_user), + workspace=Depends(require_permission("catalog.manage")), db=Depends(get_db), providers=Depends(get_providers), + db_id=Depends(get_current_db_id), ): """Return AI + schema-derived catalog suggestions (not yet saved).""" try: - return await ctrl.suggest(user_token["user_id"], db, providers) + return await ctrl.suggest(workspace["workspace_id"], db, providers, db_id) except HTTPException: raise except Exception: diff --git a/backend/app/routes/connections.py b/backend/app/routes/connections.py index dfe6f22b..d2d48512 100644 --- a/backend/app/routes/connections.py +++ b/backend/app/routes/connections.py @@ -2,17 +2,23 @@ from fastapi import APIRouter, Depends, HTTPException -from backend.app.dependencies import get_current_user +from backend.app.dependencies import require_permission +from pydantic import BaseModel import backend.app.pgdatabase as mdb + +class ConnectionUpdate(BaseModel): + alias_name: str | None + + log = logging.getLogger(__name__) router = APIRouter() @router.get("/api/connections") -async def get_connections(user_token=Depends(get_current_user)): +async def get_connections(workspace=Depends(require_permission("connections.view"))): try: - connections = await mdb.get_recent_connections(user_token["user_id"]) + connections = await mdb.get_recent_connections(workspace["workspace_id"]) for c in connections: c.pop("db_url", None) return {"connections": connections} @@ -22,12 +28,34 @@ async def get_connections(user_token=Depends(get_current_user)): @router.delete("/api/connections/{connection_id}") -async def delete_connection(connection_id: str, user_token=Depends(get_current_user)): +async def delete_connection( + connection_id: str, workspace=Depends(require_permission("connections.manage")) +): try: deleted = await mdb.delete_recent_connection( - user_token["user_id"], connection_id + workspace["workspace_id"], connection_id ) return {"ok": deleted} except Exception: log.exception("Failed to delete connection") raise HTTPException(500, "Failed to delete connection") + + +@router.patch("/api/connections/{connection_id}") +async def update_connection( + connection_id: str, + req: ConnectionUpdate, + workspace=Depends(require_permission("connections.manage")), +): + try: + updated = await mdb.update_recent_connection_alias( + workspace["workspace_id"], connection_id, req.alias_name + ) + if not updated: + raise HTTPException(404, "Connection not found") + return {"ok": True} + except HTTPException: + raise + except Exception: + log.exception("Failed to update connection alias") + raise HTTPException(500, "Failed to update connection") diff --git a/backend/app/routes/conversations.py b/backend/app/routes/conversations.py index b41ff5d3..a4134a4f 100644 --- a/backend/app/routes/conversations.py +++ b/backend/app/routes/conversations.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel -from backend.app.dependencies import get_current_user +from backend.app.dependencies import require_permission import backend.app.controllers.conversations as ctrl log = logging.getLogger(__name__) @@ -20,10 +20,11 @@ class RenameConversationReq(BaseModel): @router.get("") -async def list_conversations(user_token=Depends(get_current_user)): +async def list_conversations(workspace=Depends(require_permission("queries.execute"))): try: - user_id = user_token["user_id"] - convs = await ctrl.list_conversations(user_id) + workspace_id = workspace["workspace_id"] + user_id = workspace["user_id"] + convs = await ctrl.list_conversations(workspace_id, user_id) return JSONResponse({"conversations": convs}) except Exception: log.exception("Failed to list conversations") @@ -33,11 +34,13 @@ async def list_conversations(user_token=Depends(get_current_user)): @router.post("") async def create_conversation( req: CreateConversationReq, - user_token=Depends(get_current_user), + workspace=Depends(require_permission("queries.execute")), ): try: - user_id = user_token["user_id"] + workspace_id = workspace["workspace_id"] + user_id = workspace["user_id"] conv = await ctrl.create_conversation( + workspace_id, user_id, title=req.title, database_id=req.database_id, @@ -51,11 +54,12 @@ async def create_conversation( @router.get("/{conversation_id}") async def get_conversation( conversation_id: str, - user_token=Depends(get_current_user), + workspace=Depends(require_permission("queries.execute")), ): try: - user_id = user_token["user_id"] - conv = await ctrl.get_conversation(user_id, conversation_id) + workspace_id = workspace["workspace_id"] + user_id = workspace["user_id"] + conv = await ctrl.get_conversation(workspace_id, user_id, conversation_id) if not conv: raise HTTPException(status_code=404, detail="Conversation not found") return JSONResponse(conv) @@ -70,11 +74,14 @@ async def get_conversation( async def rename_conversation( conversation_id: str, req: RenameConversationReq, - user_token=Depends(get_current_user), + workspace=Depends(require_permission("queries.execute")), ): try: - user_id = user_token["user_id"] - success = await ctrl.rename_conversation(user_id, conversation_id, req.title) + workspace_id = workspace["workspace_id"] + user_id = workspace["user_id"] + success = await ctrl.rename_conversation( + workspace_id, user_id, conversation_id, req.title + ) if not success: raise HTTPException( status_code=404, detail="Conversation not found or unauthorized" @@ -90,11 +97,12 @@ async def rename_conversation( @router.delete("/{conversation_id}") async def delete_conversation( conversation_id: str, - user_token=Depends(get_current_user), + workspace=Depends(require_permission("queries.execute")), ): try: - user_id = user_token["user_id"] - success = await ctrl.delete_conversation(user_id, conversation_id) + workspace_id = workspace["workspace_id"] + user_id = workspace["user_id"] + success = await ctrl.delete_conversation(workspace_id, user_id, conversation_id) if not success: raise HTTPException( status_code=404, detail="Conversation not found or unauthorized" @@ -108,10 +116,11 @@ async def delete_conversation( @router.delete("") -async def clear_conversations(user_token=Depends(get_current_user)): +async def clear_conversations(workspace=Depends(require_permission("queries.execute"))): try: - user_id = user_token["user_id"] - await ctrl.clear_conversations(user_id) + workspace_id = workspace["workspace_id"] + user_id = workspace["user_id"] + await ctrl.clear_conversations(workspace_id, user_id) return JSONResponse({"message": "Cleared successfully"}) except Exception: log.exception("Failed to clear conversations") diff --git a/backend/app/routes/dashboards.py b/backend/app/routes/dashboards.py new file mode 100644 index 00000000..19580df6 --- /dev/null +++ b/backend/app/routes/dashboards.py @@ -0,0 +1,232 @@ +import logging +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from typing import Optional, List, Dict, Any +from backend.app.dependencies import require_permission, get_current_user +import backend.app.controllers.dashboards as ctrl + +log = logging.getLogger(__name__) +router = APIRouter(prefix="/api/dashboards", tags=["dashboards"]) + + +class CreateDashboardReq(BaseModel): + name: str + description: Optional[str] = None + + +class UpdateDashboardReq(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + + +class AddPanelReq(BaseModel): + saved_query_id: Optional[str] = None + title: Optional[str] = None + visualization_type: Optional[str] = None + viz_config: Optional[dict] = None + position: Optional[dict] = None + + +class UpdatePanelReq(BaseModel): + title: Optional[str] = None + visualization_type: Optional[str] = None + viz_config: Optional[dict] = None + position: Optional[dict] = None + + +class BatchUpdatePanelPositionReq(BaseModel): + updates: List[Dict[str, Any]] # {"id": panel_id, "position": {...}} + + +@router.get("") +async def list_dashboards(workspace=Depends(require_permission("dashboards.view"))): + try: + dashboards = await ctrl.list_dashboards(workspace["workspace_id"]) + return JSONResponse({"dashboards": dashboards}) + except Exception: + log.exception("Failed to list dashboards") + raise HTTPException(500, "Failed to load dashboards") + + +@router.post("") +async def create_dashboard( + req: CreateDashboardReq, + workspace=Depends(require_permission("dashboards.create")), + user=Depends(get_current_user), +): + try: + dash = await ctrl.create_dashboard( + workspace["workspace_id"], user["user_id"], req.name, req.description + ) + return JSONResponse(dash) + except HTTPException: + raise + except Exception: + log.exception("Failed to create dashboard") + raise HTTPException(500, "Failed to create dashboard") + + +@router.get("/{dashboard_id}") +async def get_dashboard( + dashboard_id: str, workspace=Depends(require_permission("dashboards.view")) +): + try: + dash = await ctrl.get_dashboard(workspace["workspace_id"], dashboard_id) + if not dash: + raise HTTPException(404, "Dashboard not found") + return JSONResponse(dash) + except HTTPException: + raise + except Exception: + log.exception("Failed to get dashboard") + raise HTTPException(500, "Failed to load dashboard") + + +@router.patch("/{dashboard_id}") +async def update_dashboard( + dashboard_id: str, + req: UpdateDashboardReq, + workspace=Depends(require_permission("dashboards.manage")), +): + try: + success = await ctrl.update_dashboard( + workspace["workspace_id"], dashboard_id, req.model_dump(exclude_unset=True) + ) + if not success: + raise HTTPException(404, "Dashboard not found") + return JSONResponse({"message": "Updated successfully"}) + except HTTPException: + raise + except Exception: + log.exception("Failed to update dashboard") + raise HTTPException(500, "Failed to update dashboard") + + +@router.delete("/{dashboard_id}") +async def delete_dashboard( + dashboard_id: str, workspace=Depends(require_permission("dashboards.manage")) +): + try: + success = await ctrl.delete_dashboard(workspace["workspace_id"], dashboard_id) + if not success: + raise HTTPException(404, "Dashboard not found") + return JSONResponse({"message": "Deleted successfully"}) + except HTTPException: + raise + except Exception: + log.exception("Failed to delete dashboard") + raise HTTPException(500, "Failed to delete dashboard") + + +@router.get("/{dashboard_id}/data") +async def get_dashboard_data( + dashboard_id: str, + request: Request, + workspace=Depends(require_permission("dashboards.view")), + _execute_permission=Depends(require_permission("queries.execute")), +): + try: + db_manager = request.app.state.db + results = await ctrl.execute_dashboard_queries( + workspace["workspace_id"], dashboard_id, db_manager + ) + if results is None: + raise HTTPException(404, "Dashboard not found") + return JSONResponse(results) + except HTTPException: + raise + except Exception: + log.exception("Failed to execute dashboard queries") + raise HTTPException(500, "Failed to execute dashboard queries") + + +# --- Panels --- + + +@router.post("/{dashboard_id}/panels") +async def add_panel( + dashboard_id: str, + req: AddPanelReq, + workspace=Depends(require_permission("dashboards.manage")), +): + try: + panel = await ctrl.add_panel( + workspace["workspace_id"], dashboard_id, req.model_dump(exclude_unset=True) + ) + return JSONResponse(panel) + except HTTPException: + raise + except ValueError as e: + raise HTTPException(404, str(e)) + except Exception: + log.exception("Failed to add panel") + raise HTTPException(500, "Failed to add panel") + + +@router.patch("/{dashboard_id}/panels/batch") +async def batch_update_panels( + dashboard_id: str, + req: BatchUpdatePanelPositionReq, + workspace=Depends(require_permission("dashboards.manage")), +): + try: + success = await ctrl.update_panels_batch( + workspace["workspace_id"], + dashboard_id, + req.model_dump(exclude_unset=True)["updates"], + ) + if not success: + raise HTTPException(404, "Dashboard not found") + return JSONResponse({"message": "Updated successfully"}) + except HTTPException: + raise + except Exception: + log.exception("Failed to batch update panels") + raise HTTPException(500, "Failed to update panels") + + +@router.patch("/{dashboard_id}/panels/{panel_id}") +async def update_panel( + dashboard_id: str, + panel_id: str, + req: UpdatePanelReq, + workspace=Depends(require_permission("dashboards.manage")), +): + try: + success = await ctrl.update_panel( + workspace["workspace_id"], + dashboard_id, + panel_id, + req.model_dump(exclude_unset=True), + ) + if not success: + raise HTTPException(404, "Panel not found") + return JSONResponse({"message": "Updated successfully"}) + except HTTPException: + raise + except ValueError as e: + raise HTTPException(404, str(e)) + except Exception: + log.exception("Failed to update panel") + raise HTTPException(500, "Failed to update panel") + + +@router.delete("/{dashboard_id}/panels/{panel_id}") +async def delete_panel( + dashboard_id: str, + panel_id: str, + workspace=Depends(require_permission("dashboards.manage")), +): + try: + success = await ctrl.delete_panel( + workspace["workspace_id"], dashboard_id, panel_id + ) + if not success: + raise HTTPException(404, "Panel not found") + return JSONResponse({"message": "Deleted successfully"}) + except HTTPException: + raise + except Exception: + log.exception("Failed to delete panel") + raise HTTPException(500, "Failed to delete panel") diff --git a/backend/app/routes/database.py b/backend/app/routes/database.py index 7554c623..ab0466a8 100644 --- a/backend/app/routes/database.py +++ b/backend/app/routes/database.py @@ -1,52 +1,100 @@ +import logging + from fastapi import APIRouter, Depends, HTTPException -from backend.app.dependencies import get_current_user, get_db, get_kb, get_cfg +from backend.app.dependencies import ( + require_permission, + get_db, + get_kb, + get_cfg, + get_current_db_id, +) from backend.app.models.api import ConnectReq import backend.app.controllers.database as ctrl import backend.app.pgdatabase as mdb +log = logging.getLogger(__name__) router = APIRouter() +@router.get("/api/databases") +async def list_databases(workspace=Depends(require_permission("connections.view"))): + return await mdb.get_recent_connections(workspace["workspace_id"], limit=50) + + @router.post("/api/connect") async def connect( req: ConnectReq, - user_token=Depends(get_current_user), + workspace=Depends(require_permission("connections.manage")), + _catalog_permission=Depends(require_permission("catalog.view")), db=Depends(get_db), kb=Depends(get_kb), cfg=Depends(get_cfg), ): - return await ctrl.connect(db, kb, cfg, req, user_id=user_token["user_id"]) + return await ctrl.connect( + db, + kb, + cfg, + req, + workspace_id=workspace["workspace_id"], + user_id=workspace.get("user_id"), + ) @router.post("/api/connect-sample") async def connect_sample( - user_token=Depends(get_current_user), + workspace=Depends(require_permission("connections.manage")), + _catalog_permission=Depends(require_permission("catalog.view")), db=Depends(get_db), kb=Depends(get_kb), cfg=Depends(get_cfg), ): - return await ctrl.connect_sample(db, kb, cfg, user_id=user_token["user_id"]) + return await ctrl.connect_sample( + db, + kb, + cfg, + workspace_id=workspace["workspace_id"], + user_id=workspace.get("user_id"), + ) @router.post("/api/disconnect") async def disconnect( - user_token=Depends(get_current_user), db=Depends(get_db), cfg=Depends(get_cfg) + workspace=Depends(require_permission("connections.manage")), + db_id: str = Depends(get_current_db_id), + db=Depends(get_db), + cfg=Depends(get_cfg), ): - user_id = user_token["user_id"] - return await ctrl.disconnect(user_id, db, cfg) + return await ctrl.disconnect( + workspace["workspace_id"], + db, + cfg, + db_id=db_id, + user_id=workspace.get("user_id"), + ) @router.get("/api/schema") async def schema( - refresh: bool = False, user_token=Depends(get_current_user), db=Depends(get_db) + refresh: bool = False, + workspace=Depends(require_permission("connections.view_schema")), + db_id: str = Depends(get_current_db_id), + db=Depends(get_db), ): - return await ctrl.get_schema(user_token["user_id"], db, refresh) + return await ctrl.get_schema(workspace["workspace_id"], db, refresh, db_id=db_id) @router.post("/api/reconnect") async def reconnect( request_body: dict, - user_token=Depends(get_current_user), + # Switching to a database the workspace already has is a *use* action, not a + # *manage* one: reconnect only ever targets a stored connection (looked up by + # db_id below and 404'd otherwise, using the saved URL — never a caller + # supplied one), so it can't add new databases. Gating it on connections.view + # lets members pick between the workspace's databases from the switcher; + # adding a brand-new connection still requires connections.manage via + # /api/connect. + workspace=Depends(require_permission("connections.view")), + _catalog_permission=Depends(require_permission("catalog.view")), db=Depends(get_db), kb=Depends(get_kb), cfg=Depends(get_cfg), @@ -54,8 +102,26 @@ async def reconnect( db_id = request_body.get("db_id", "") if not db_id: raise HTTPException(400, "db_id is required") - conn = await mdb.get_recent_connection_by_db_id(user_token["user_id"], db_id) + try: + conn = await mdb.get_recent_connection_by_db_id( + workspace["workspace_id"], db_id + ) + except RuntimeError: + log.exception("Could not decrypt stored connection for db_id=%s", db_id) + raise HTTPException( + 409, + "Saved credentials could not be decrypted — please re-add this connection.", + ) if not conn or not conn.get("db_url"): raise HTTPException(404, "Saved connection not found") - req = ConnectReq(db_url=conn["db_url"]) - return await ctrl.connect(db, kb, cfg, req, user_id=user_token["user_id"]) + # Carry the stored alias through, so reconnecting doesn't leave the header + # and the switcher labelling a named database by its host again. + req = ConnectReq(db_url=conn["db_url"], alias_name=conn.get("alias_name")) + return await ctrl.connect( + db, + kb, + cfg, + req, + workspace_id=workspace["workspace_id"], + user_id=workspace.get("user_id"), + ) diff --git a/backend/app/routes/history.py b/backend/app/routes/history.py index 4e05ced1..ba216446 100644 --- a/backend/app/routes/history.py +++ b/backend/app/routes/history.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import JSONResponse -from backend.app.dependencies import get_current_user +from backend.app.dependencies import require_permission import backend.app.pgdatabase as db log = logging.getLogger(__name__) @@ -12,11 +12,11 @@ @router.get("") async def get_history( limit: int = Query(default=100, ge=1, le=500), - user_token=Depends(get_current_user), + workspace=Depends(require_permission("queries.execute")), ): try: - user_id = user_token["user_id"] - history = await db.get_query_history(user_id, limit) + workspace_id = workspace["workspace_id"] + history = await db.get_query_history(workspace_id, limit) return JSONResponse({"history": history}) except Exception: log.exception("Failed to get query history") @@ -24,10 +24,10 @@ async def get_history( @router.get("/stats") -async def get_stats(user_token=Depends(get_current_user)): +async def get_stats(workspace=Depends(require_permission("queries.execute"))): try: - user_id = user_token["user_id"] - stats = await db.get_query_stats(user_id) + workspace_id = workspace["workspace_id"] + stats = await db.get_query_stats(workspace_id) return JSONResponse(stats) except Exception: log.exception("Failed to get query stats") @@ -35,10 +35,13 @@ async def get_stats(user_token=Depends(get_current_user)): @router.delete("/{entry_id}") -async def delete_entry(entry_id: str, user_token=Depends(get_current_user)): +async def delete_entry( + entry_id: str, + workspace=Depends(require_permission("queries.execute")), +): try: - user_id = user_token["user_id"] - success = await db.delete_history_entry(user_id, entry_id) + workspace_id = workspace["workspace_id"] + success = await db.delete_history_entry(workspace_id, entry_id) if not success: raise HTTPException( status_code=404, detail="Entry not found or unauthorized" @@ -52,10 +55,10 @@ async def delete_entry(entry_id: str, user_token=Depends(get_current_user)): @router.delete("") -async def clear_history(user_token=Depends(get_current_user)): +async def clear_history(workspace=Depends(require_permission("queries.execute"))): try: - user_id = user_token["user_id"] - await db.clear_history(user_id) + workspace_id = workspace["workspace_id"] + await db.clear_history(workspace_id) return JSONResponse({"message": "Cleared successfully"}) except Exception: log.exception("Failed to clear history") diff --git a/backend/app/routes/onboard.py b/backend/app/routes/onboard.py index a345c281..4f812b53 100644 --- a/backend/app/routes/onboard.py +++ b/backend/app/routes/onboard.py @@ -1,7 +1,12 @@ import logging from fastapi import APIRouter, Depends, Request, HTTPException -from backend.app.dependencies import get_current_user, get_db, get_kb, get_providers +from backend.app.dependencies import ( + require_permission, + get_db, + get_kb, + get_providers, +) from backend.app.models.api import SaveOnboardReq from backend.app.ratelimit import limiter import backend.app.controllers.onboard as ctrl @@ -15,13 +20,13 @@ async def onboard_save( request: Request, req: SaveOnboardReq, - user_token=Depends(get_current_user), + workspace=Depends(require_permission("catalog.manage")), db=Depends(get_db), kb=Depends(get_kb), ): try: - user_id = user_token["user_id"] - return await ctrl.save(user_id, db, kb, req) + workspace_id = workspace["workspace_id"] + return await ctrl.save(workspace_id, db, kb, req) except HTTPException: raise except Exception: @@ -33,14 +38,15 @@ async def onboard_save( @limiter.limit("5/minute") async def api_generate_starters( request: Request, - user_token=Depends(get_current_user), + workspace=Depends(require_permission("catalog.manage")), + _execute_permission=Depends(require_permission("queries.execute")), db=Depends(get_db), kb=Depends(get_kb), providers=Depends(get_providers), ): try: - user_id = user_token["user_id"] - return await ctrl.generate_starters_async(user_id, db, kb, providers) + workspace_id = workspace["workspace_id"] + return await ctrl.generate_starters_async(workspace_id, db, kb, providers) except HTTPException: raise except Exception: diff --git a/backend/app/routes/query.py b/backend/app/routes/query.py index f163503d..2f657bcb 100644 --- a/backend/app/routes/query.py +++ b/backend/app/routes/query.py @@ -3,18 +3,21 @@ from fastapi import APIRouter, Depends, Request, HTTPException from fastapi.responses import StreamingResponse from backend.app.dependencies import ( - get_current_user, + require_permission, + workspace_has_permission, get_db, get_kb, get_cfg, get_providers, get_session_log, + get_current_db_id, ) from backend.app.models.api import QueryReq, FeedbackReq, VerifyReq, RawSQLReq from backend.app.ratelimit import limiter import backend.app.controllers.query as ctrl import backend.app.pgdatabase as mdb from backend.app.pgdatabase.conversations import MAX_SAVED_ROWS +from backend.app.controllers.activity import log_activity log = logging.getLogger(__name__) router = APIRouter() @@ -34,16 +37,28 @@ async def _format_sse(stream): async def query( request: Request, req: QueryReq, - user_token=Depends(get_current_user), + workspace=Depends(require_permission("queries.execute")), db=Depends(get_db), kb=Depends(get_kb), cfg=Depends(get_cfg), providers=Depends(get_providers), session_log=Depends(get_session_log), + db_id=Depends(get_current_db_id), ) -> dict: try: - user_id = user_token["user_id"] - out = await ctrl.run_query(user_id, db, kb, cfg, providers, session_log, req) + workspace_id = workspace["workspace_id"] + user_id = workspace.get("user_id") + out = await ctrl.run_query( + workspace_id, + db, + kb, + cfg, + providers, + session_log, + req, + db_id=db_id, + user_id=user_id, + ) if out.get("answered") and out.get("sql"): conf = out.get("confidence", "low") @@ -52,21 +67,31 @@ async def query( ) conversation_id = req.conversation_id if conversation_id and not await mdb.conversation_owned_by( - user_id, conversation_id + workspace_id, user_id, conversation_id ): conversation_id = None try: await mdb.save_query( - user_id=user_token["user_id"], + workspace_id=workspace_id, + user_id=user_id, question=req.question, sql=out["sql"], result=out.get("rows", []), confidence=conf_str, conversation_id=conversation_id, restatement=out.get("restatement", ""), + chart=out.get("chart"), ) if conversation_id: - await mdb.touch_conversation(conversation_id) + await mdb.touch_conversation(conversation_id, user_id) + await log_activity( + workspace_id, + user_id, + "query.executed", + "query", + None, + {"question": req.question, "db_id": db_id, "confidence": conf_str}, + ) except Exception: log.warning("Failed to persist query history", exc_info=True) return out @@ -82,17 +107,27 @@ async def query( async def query_stream( request: Request, req: QueryReq, - user_token=Depends(get_current_user), + workspace=Depends(require_permission("queries.execute")), db=Depends(get_db), kb=Depends(get_kb), cfg=Depends(get_cfg), providers=Depends(get_providers), session_log=Depends(get_session_log), + db_id=Depends(get_current_db_id), ) -> StreamingResponse: try: - user_id = user_token["user_id"] + workspace_id = workspace["workspace_id"] + user_id = workspace.get("user_id") stream = ctrl.run_query_stream( - user_id, db, kb, cfg, providers, session_log, req + workspace_id, + db, + kb, + cfg, + providers, + session_log, + req, + db_id=db_id, + user_id=user_id, ) return StreamingResponse( @@ -113,14 +148,23 @@ async def query_stream( @router.post("/api/feedback") async def feedback( req: FeedbackReq, - user_token=Depends(get_current_user), + workspace=Depends(require_permission("queries.execute")), db=Depends(get_db), kb=Depends(get_kb), session_log=Depends(get_session_log), + db_id=Depends(get_current_db_id), ) -> dict: try: - user_id = user_token["user_id"] - return await ctrl.feedback(user_id, db, kb, session_log, req) + workspace_id = workspace["workspace_id"] + # Positive feedback promotes a Q&A/SQL pair into shared verified knowledge, + # so it must satisfy the same catalog-manage gate as /api/verify. + if req.verdict == "correct" and not await workspace_has_permission( + workspace, "catalog.manage" + ): + raise HTTPException( + status_code=403, detail="Permission 'catalog.manage' required" + ) + return await ctrl.feedback(workspace_id, db, kb, session_log, req, db_id=db_id) except HTTPException: raise except Exception: @@ -131,13 +175,23 @@ async def feedback( @router.post("/api/verify") async def verify( req: VerifyReq, - user_token=Depends(get_current_user), + workspace=Depends(require_permission("catalog.manage")), db=Depends(get_db), kb=Depends(get_kb), + db_id=Depends(get_current_db_id), ) -> dict: try: - user_id = user_token["user_id"] - return await ctrl.verify(user_id, db, kb, req) + workspace_id = workspace["workspace_id"] + result = await ctrl.verify(workspace_id, db, kb, req, db_id=db_id) + await log_activity( + workspace_id, + workspace.get("user_id"), + "knowledge.verified", + "knowledge", + None, + {"question": req.question, "db_id": db_id}, + ) + return result except HTTPException: raise except Exception: @@ -150,30 +204,47 @@ async def verify( async def execute( request: Request, req: RawSQLReq, - user_token=Depends(get_current_user), + workspace=Depends(require_permission("queries.execute")), db=Depends(get_db), + db_id=Depends(get_current_db_id), ) -> dict: try: - user_id = user_token["user_id"] - out = await ctrl.execute(user_id, db, req) + workspace_id = workspace["workspace_id"] + user_id = workspace.get("user_id") + out = await ctrl.execute(workspace_id, db, req, db_id=db_id) conversation_id = req.conversation_id if conversation_id and not await mdb.conversation_owned_by( - user_id, conversation_id + workspace_id, user_id, conversation_id ): conversation_id = None try: - await mdb.save_query( - user_id=user_id, - question=req.sql, - sql=req.sql, - result=(out.get("rows") or [])[:MAX_SAVED_ROWS], - confidence="High", - conversation_id=conversation_id, - restatement="Direct SQL execution", + if req.save_history: + await mdb.save_query( + workspace_id=workspace_id, + user_id=user_id, + question=req.sql, + sql=req.sql, + result=(out.get("rows") or [])[:MAX_SAVED_ROWS], + confidence="High", + conversation_id=conversation_id, + restatement="Direct SQL execution", + ) + if conversation_id: + await mdb.touch_conversation(conversation_id, user_id) + await log_activity( + workspace_id, + user_id, + "query.executed", + "query", + None, + { + "is_raw_sql": True, + "db_id": db_id, + "confidence": "High", + "is_rerun": not req.save_history, + }, ) - if conversation_id: - await mdb.touch_conversation(conversation_id) except Exception: log.warning("Failed to persist direct SQL history", exc_info=True) return out @@ -189,13 +260,14 @@ async def execute( async def explain( request: Request, req: RawSQLReq, - user_token=Depends(get_current_user), + workspace=Depends(require_permission("queries.explain")), db=Depends(get_db), providers=Depends(get_providers), + db_id=Depends(get_current_db_id), ) -> dict: try: - user_id = user_token["user_id"] - return await ctrl.explain(user_id, db, providers, req) + workspace_id = workspace["workspace_id"] + return await ctrl.explain(workspace_id, db, providers, req, db_id=db_id) except HTTPException: raise except Exception: diff --git a/backend/app/routes/saved_queries.py b/backend/app/routes/saved_queries.py new file mode 100644 index 00000000..9514aa35 --- /dev/null +++ b/backend/app/routes/saved_queries.py @@ -0,0 +1,111 @@ +import logging +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from typing import Optional +from backend.app.dependencies import require_permission, get_current_user +import backend.app.controllers.dashboards as ctrl + +log = logging.getLogger(__name__) +router = APIRouter(prefix="/api/saved-queries", tags=["saved_queries"]) + + +class SaveQueryReq(BaseModel): + name: str + description: Optional[str] = None + question: Optional[str] = None + sql: Optional[str] = None + columns: Optional[list] = None + database_id: Optional[str] = None + visualization_type: str = "table" + viz_config: Optional[dict] = None + last_result: Optional[list] = None + + +class UpdateSavedQueryReq(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + visualization_type: Optional[str] = None + viz_config: Optional[dict] = None + + +@router.get("") +async def list_saved_queries( + workspace=Depends(require_permission("queries.execute")), +): + try: + sqs = await ctrl.list_saved_queries(workspace["workspace_id"]) + return JSONResponse({"saved_queries": sqs}) + except Exception: + log.exception("Failed to list saved queries") + raise HTTPException(500, "Failed to load saved queries") + + +@router.post("") +async def create_saved_query( + req: SaveQueryReq, + workspace=Depends(require_permission("queries.save")), + user=Depends(get_current_user), +): + try: + sq = await ctrl.create_saved_query( + workspace["workspace_id"], + user["user_id"], + req.model_dump(exclude_unset=True), + ) + return JSONResponse(sq) + except Exception: + log.exception("Failed to create saved query") + raise HTTPException(500, "Failed to save query") + + +@router.get("/{query_id}") +async def get_saved_query( + query_id: str, workspace=Depends(require_permission("queries.execute")) +): + try: + sq = await ctrl.get_saved_query(workspace["workspace_id"], query_id) + if not sq: + raise HTTPException(404, "Saved query not found") + return JSONResponse(sq) + except HTTPException: + raise + except Exception: + log.exception("Failed to get saved query") + raise HTTPException(500, "Failed to load saved query") + + +@router.patch("/{query_id}") +async def update_saved_query( + query_id: str, + req: UpdateSavedQueryReq, + workspace=Depends(require_permission("queries.save")), +): + try: + success = await ctrl.update_saved_query( + workspace["workspace_id"], query_id, req.model_dump(exclude_unset=True) + ) + if not success: + raise HTTPException(404, "Saved query not found") + return JSONResponse({"message": "Updated successfully"}) + except HTTPException: + raise + except Exception: + log.exception("Failed to update saved query") + raise HTTPException(500, "Failed to update saved query") + + +@router.delete("/{query_id}") +async def delete_saved_query( + query_id: str, workspace=Depends(require_permission("queries.delete_saved")) +): + try: + success = await ctrl.delete_saved_query(workspace["workspace_id"], query_id) + if not success: + raise HTTPException(404, "Saved query not found") + return JSONResponse({"message": "Deleted successfully"}) + except HTTPException: + raise + except Exception: + log.exception("Failed to delete saved query") + raise HTTPException(500, "Failed to delete saved query") diff --git a/backend/app/routes/system.py b/backend/app/routes/system.py index 9c06a520..9d9db12f 100644 --- a/backend/app/routes/system.py +++ b/backend/app/routes/system.py @@ -1,12 +1,14 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Header from fastapi.responses import JSONResponse from sqlalchemy import text from backend.app.dependencies import ( get_current_user, + get_current_workspace, get_db, get_cfg, get_kb, get_providers, + get_current_db_id, ) from backend.app.models.api import ConfigUpdate from backend.app.secrets import get_supabase_url, get_supabase_anon_key @@ -18,6 +20,8 @@ @router.get("/api/state") async def state( user_token=Depends(get_current_user), + x_workspace_id: str = Header(None), + x_db_id: str = Depends(get_current_db_id), db=Depends(get_db), cfg=Depends(get_cfg), kb=Depends(get_kb), @@ -29,7 +33,11 @@ async def state( The current application state. """ user_id = user_token["user_id"] - return await ctrl.get_state(user_id, db, cfg, kb) + verified_workspace_id = None + if x_workspace_id: + workspace = await get_current_workspace(x_workspace_id, user_token) + verified_workspace_id = workspace["workspace_id"] + return await ctrl.get_state(user_id, verified_workspace_id, x_db_id, db, cfg, kb) @router.post("/api/tour-complete") diff --git a/backend/app/routes/workspaces.py b/backend/app/routes/workspaces.py new file mode 100644 index 00000000..fd1a0019 --- /dev/null +++ b/backend/app/routes/workspaces.py @@ -0,0 +1,284 @@ +import csv +import io +import json + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from backend.app.dependencies import ( + get_current_user, + get_current_workspace, + require_permission, +) +from backend.app.pgdatabase import get_user_by_id +from backend.app.models.workspace_api import ( + WorkspaceBulkInviteCreate, + WorkspaceCreate, + WorkspaceMemberRoleUpdate, + WorkspaceInviteCreate, + WorkspaceOwnershipTransfer, + WorkspaceUpdate, + WorkspaceSettingsUpdate, +) +import backend.app.controllers.workspaces as ctrl +import backend.app.controllers.activity as activity_ctrl + +router = APIRouter() + + +@router.get("/api/workspaces") +async def list_workspaces(user=Depends(get_current_user)): + user_id = user.get("user_id", user.get("sub")) + return await ctrl.list_workspaces(user_id) + + +@router.post("/api/workspaces") +async def create_workspace(req: WorkspaceCreate, user=Depends(get_current_user)): + user_id = user.get("user_id", user.get("sub")) + return await ctrl.create_workspace(user_id, req.name) + + +@router.get("/api/workspaces/{workspace_id}") +async def get_workspace(workspace_id: str, user=Depends(get_current_user)): + user_id = user.get("user_id", user.get("sub")) + return await ctrl.get_workspace(workspace_id, user_id) + + +@router.get("/api/workspaces/{workspace_id}/settings") +async def get_workspace_settings( + workspace_id: str, + workspace=Depends(require_permission("workspace.settings")), +): + return await ctrl.get_workspace_settings(workspace["workspace_id"]) + + +@router.patch("/api/workspaces/{workspace_id}/settings") +async def update_workspace_settings( + workspace_id: str, + req: WorkspaceSettingsUpdate, + workspace=Depends(get_current_workspace), +): + if workspace["role"] != "owner": + raise HTTPException(403, "Owner role required") + return await ctrl.update_workspace_settings( + workspace["workspace_id"], + req.model_dump(exclude_unset=True), + actor_id=workspace["user_id"], + ) + + +@router.patch("/api/workspaces/{workspace_id}") +async def update_workspace( + workspace_id: str, + req: WorkspaceUpdate, + workspace=Depends(require_permission("workspace.update")), +): + return await ctrl.update_workspace( + workspace["workspace_id"], req.name, actor_id=workspace["user_id"] + ) + + +@router.delete("/api/workspaces/{workspace_id}") +async def delete_workspace( + workspace_id: str, + workspace=Depends(get_current_workspace), +): + if workspace["role"] != "owner": + raise HTTPException(403, "Owner role required") + return await ctrl.delete_workspace( + workspace["workspace_id"], actor_id=workspace["user_id"] + ) + + +@router.post("/api/workspaces/{workspace_id}/leave") +async def leave_workspace( + workspace_id: str, + workspace=Depends(get_current_workspace), +): + return await ctrl.leave_workspace(workspace["workspace_id"], workspace["user_id"]) + + +@router.get("/api/workspaces/{workspace_id}/members") +async def list_members( + workspace_id: str, workspace=Depends(require_permission("members.view")) +): + return await ctrl.list_members(workspace["workspace_id"]) + + +@router.post("/api/workspaces/{workspace_id}/members") +async def invite_user( + workspace_id: str, + req: WorkspaceInviteCreate, + workspace=Depends(require_permission("members.invite")), +): + return await ctrl.invite_user( + workspace["workspace_id"], req.email, req.role, workspace["user_id"] + ) + + +@router.post("/api/workspaces/{workspace_id}/members/bulk") +async def bulk_invite_users( + workspace_id: str, + req: WorkspaceBulkInviteCreate, + workspace=Depends(require_permission("members.invite")), +): + return await ctrl.bulk_invite( + workspace["workspace_id"], req.emails, req.role, workspace["user_id"] + ) + + +@router.post("/api/workspaces/{workspace_id}/transfer-ownership") +async def transfer_ownership( + workspace_id: str, + req: WorkspaceOwnershipTransfer, + workspace=Depends(get_current_workspace), +): + if workspace["role"] != "owner": + raise HTTPException(403, "Owner role required") + return await ctrl.transfer_ownership( + workspace["workspace_id"], workspace["user_id"], req.user_id + ) + + +@router.put("/api/workspaces/{workspace_id}/members/{user_id}") +async def update_member_role( + workspace_id: str, + user_id: str, + req: WorkspaceMemberRoleUpdate, + workspace=Depends(require_permission("members.update_role")), +): + return await ctrl.update_member_role( + workspace["workspace_id"], + user_id, + req.role, + actor_id=workspace["user_id"], + actor_role=workspace["role"], + ) + + +@router.delete("/api/workspaces/{workspace_id}/members/{user_id}") +async def remove_member( + workspace_id: str, + user_id: str, + workspace=Depends(require_permission("members.remove")), +): + return await ctrl.remove_member( + workspace["workspace_id"], + user_id, + actor_id=workspace["user_id"], + actor_role=workspace["role"], + ) + + +@router.get("/api/workspaces/{workspace_id}/invites") +async def list_pending_invites( + workspace_id: str, workspace=Depends(require_permission("members.invite")) +): + return await ctrl.list_pending_invites(workspace["workspace_id"]) + + +@router.delete("/api/workspaces/{workspace_id}/invites/{invite_id}") +async def rescind_invite( + workspace_id: str, + invite_id: str, + workspace=Depends(require_permission("members.invite")), +): + return await ctrl.rescind_invite( + workspace["workspace_id"], invite_id, actor_id=workspace["user_id"] + ) + + +@router.post("/api/workspaces/{workspace_id}/invites/{invite_id}/resend") +async def resend_invite( + workspace_id: str, + invite_id: str, + workspace=Depends(require_permission("members.invite")), +): + return await ctrl.resend_invite( + workspace["workspace_id"], invite_id, actor_id=workspace["user_id"] + ) + + +@router.get("/api/workspaces/invites/me") +async def get_invites(user=Depends(get_current_user)): + user_id = user.get("user_id", user.get("sub")) + user_details = await get_user_by_id(user_id) + email = user_details.get("email") if user_details else None + if not email: + return [] + return await ctrl.get_invites(email) + + +@router.post("/api/workspaces/invites/{token}/accept") +async def accept_invite(token: str, user=Depends(get_current_user)): + user_id = user.get("user_id", user.get("sub")) + user_details = await get_user_by_id(user_id) + email = user_details.get("email") if user_details else None + if not email: + raise HTTPException(400, "User email not found") + return await ctrl.accept_invite(token, user_id, email) + + +@router.get("/api/workspaces/{workspace_id}/activity") +async def get_activity_log( + workspace_id: str, + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), + event_type: str = Query(None), + workspace=Depends(require_permission("activity.view")), +): + return await activity_ctrl.get_workspace_activity( + workspace["workspace_id"], limit, offset, event_type + ) + + +@router.get("/api/workspaces/{workspace_id}/activity/export") +async def export_activity_log( + workspace_id: str, + event_type: str = Query(None), + workspace=Depends(require_permission("activity.export")), +): + async def rows(): + buf = io.StringIO() + writer = csv.writer(buf) + + def flush(): + buf.seek(0) + chunk = buf.read() + buf.seek(0) + buf.truncate(0) + return chunk + + writer.writerow( + [ + "timestamp", + "actor_email", + "event_type", + "resource_type", + "resource_id", + "details", + ] + ) + yield flush() + + async for a in activity_ctrl.iter_workspace_activity( + workspace["workspace_id"], event_type + ): + created = a.get("created_at") + writer.writerow( + [ + created.isoformat() if created else "", + a.get("actor_email") or "", + a.get("event_type") or "", + a.get("resource_type") or "", + a.get("resource_id") or "", + json.dumps(a.get("metadata_") or {}, default=str), + ] + ) + yield flush() + + filename = f"activity-{workspace_id}.csv" + return StreamingResponse( + rows(), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/backend/app/server.py b/backend/app/server.py index 984872bd..a9070cf1 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -1,10 +1,11 @@ """FastAPI application.""" +import asyncio import logging import os import sys from pathlib import Path -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, suppress from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.concurrency import run_in_threadpool @@ -21,15 +22,20 @@ from backend.app.pgdatabase import KnowledgeService from backend.app.pgdatabase.engine import async_session -from backend.app.routes.auth import router as auth_router -from backend.app.routes.system import router as system_router -from backend.app.routes.database import router as database_router -from backend.app.routes.onboard import router as onboard_router -from backend.app.routes.query import router as query_router -from backend.app.routes.history import router as history_router -from backend.app.routes.connections import router as connections_router -from backend.app.routes.catalog import router as catalog_router -from backend.app.routes.conversations import router as conversations_router +from backend.app.routes import ( + auth, + catalog, + connections, + conversations, + database, + history, + onboard, + query, + system, + workspaces, + dashboards, + saved_queries, +) logger = logging.getLogger(__name__) @@ -80,9 +86,23 @@ async def lifespan(app): else: from backend.app.pgdatabase import dispose_db - yield + # In-process periodic pruning of the activity log. Safe here because the + # deployment is single-process; a second worker would double up the work + # (harmless, but wasteful), so the flag exists to turn it off. + cleanup_task = None + if cfgmod.ACTIVITY_CLEANUP_ENABLED: + from backend.app.controllers.activity import activity_cleanup_loop - await dispose_db() + cleanup_task = asyncio.create_task(activity_cleanup_loop()) + + try: + yield + finally: + if cleanup_task: + cleanup_task.cancel() + with suppress(asyncio.CancelledError): + await cleanup_task + await dispose_db() def create_app(initial_db_url="", readonly=True): @@ -114,7 +134,7 @@ def create_app(initial_db_url="", readonly=True): providers = ProviderManager(cfg) db = DatabaseManager(readonly=readonly) kbs = KnowledgeService(async_session) - session_log = SessionLog(cfgmod.CONFIG_DIR) + session_log = SessionLog() app = FastAPI(title="BoloDB", version="2.0.0", lifespan=lifespan) app.state.limiter = limiter @@ -148,14 +168,19 @@ def create_app(initial_db_url="", readonly=True): "in the server. Connect from the UI after signing in." ) - app.include_router(auth_router) - app.include_router(system_router) - app.include_router(database_router) - app.include_router(onboard_router) - app.include_router(query_router) - app.include_router(history_router) - app.include_router(connections_router) - app.include_router(catalog_router) - app.include_router(conversations_router) + app.include_router(auth.router) + app.include_router(system.router) + app.include_router(database.router) + app.include_router(onboard.router) + app.include_router(query.router) + app.include_router(history.router) + app.include_router(connections.router) + app.include_router(catalog.router) + app.include_router(conversations.router) + app.include_router(workspaces.router) + app.include_router(dashboards.router) + app.include_router(saved_queries.router) + + # Catch-all for API 404app return app diff --git a/backend/app/services/email.py b/backend/app/services/email.py index 93ac344b..f06a3018 100644 --- a/backend/app/services/email.py +++ b/backend/app/services/email.py @@ -96,3 +96,34 @@ async def send_password_reset_email(to: str, reset_link: str) -> bool: """ return await send_email(to, "Reset your BoloDB password", html) + + +async def send_workspace_invite_email( + to: str, workspace_name: str, invite_code: str +) -> bool: + """Send a workspace invite email.""" + import html + + safe_name = html.escape(workspace_name) + safe_code = html.escape(invite_code) + html_content = f""" + + + + +

You've been invited!

+

+ You have been invited to join the {safe_name} workspace on BoloDB. +

+
+ {safe_code} +
+

+ Copy this code and paste it into the "Join Workspace" screen to accept the invitation. +

+ + + """ + return await send_email( + to, f"Invitation to join {workspace_name} on BoloDB", html_content + ) diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100644 index 00000000..b13aac17 --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Bring the database schema up to date, then start the app. +# +# Point DATABASE_URL at a different database and the next deploy migrates it +# from wherever it is to the current head — a fresh database gets the whole +# history, an existing one gets only what it is missing. Without this the app +# starts happily against a stale schema and fails later, one endpoint at a +# time, wherever a missing table or column happens to be touched. +# +# Set RUN_MIGRATIONS=false to skip (e.g. a read-only replica, or when +# migrations are applied by a separate release step). +set -e + +if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then + if [ -z "${DATABASE_URL}" ]; then + echo "entrypoint: DATABASE_URL is not set — cannot migrate" >&2 + exit 1 + fi + + # A database that is still accepting connections gets a few chances; compose + # brings services up together and the app usually wins the race. + attempt=1 + max_attempts="${MIGRATION_MAX_ATTEMPTS:-10}" + until (cd /app/backend && PYTHONPATH=/app alembic upgrade head); do + if [ "$attempt" -ge "$max_attempts" ]; then + echo "entrypoint: migrations failed after ${attempt} attempts — refusing to start" >&2 + exit 1 + fi + echo "entrypoint: migration attempt ${attempt}/${max_attempts} failed, retrying in 3s" >&2 + attempt=$((attempt + 1)) + sleep 3 + done + echo "entrypoint: database schema is up to date" +fi + +exec "$@" diff --git a/backend/sample_data.py b/backend/sample_data.py index 71e566c3..f12b4096 100644 --- a/backend/sample_data.py +++ b/backend/sample_data.py @@ -1,75 +1,306 @@ -"""Creates TechStore sample database on first run.""" +"""Builds the sample webshop database on first run. -import sqlite3 +The data is a trimmed copy of the PostgreSQL sample database at +https://github.com/JannikArndt/PostgreSQLSampleDatabase — a webshop with 1,000 +customers, 1,000 products across ~17,700 articles, and 2,000 orders with their +positions. Upstream ships `pg_dump` output; `backend/sample_db/webshop.dump.gz` +holds just the `COPY … FROM stdin` blocks from it (see +`backend/sample_db/build_sample_dump.py`), which this module replays into a +local SQLite file so trying the sample needs no database of your own. +""" + +import gzip +import logging import os +import sqlite3 +import tempfile from pathlib import Path +logger = logging.getLogger(__name__) + +DUMP_PATH = Path(__file__).parent / "sample_db" / "webshop.dump.gz" + +# Bumped whenever the schema or data changes, so an existing sample file from an +# older release is rebuilt instead of silently serving the previous dataset. +SAMPLE_VERSION = 2 + +SCHEMA = """ +CREATE TABLE colors ( + id INTEGER PRIMARY KEY, + name TEXT, + rgb TEXT +); + +CREATE TABLE sizes ( + id INTEGER PRIMARY KEY, + gender TEXT, + category TEXT, + size TEXT, + size_us TEXT, + size_uk TEXT, + size_eu TEXT +); + +CREATE TABLE labels ( + id INTEGER PRIMARY KEY, + name TEXT, + slugname TEXT +); + +CREATE TABLE products ( + id INTEGER PRIMARY KEY, + name TEXT, + labelid INTEGER REFERENCES labels (id), + category TEXT, + gender TEXT, + currentlyactive INTEGER, + created TEXT, + updated TEXT +); + +CREATE TABLE articles ( + id INTEGER PRIMARY KEY, + productid INTEGER REFERENCES products (id), + ean TEXT, + colorid INTEGER REFERENCES colors (id), + sizeid INTEGER REFERENCES sizes (id), + description TEXT, + originalprice REAL, + reducedprice REAL, + taxrate REAL, + discountinpercent INTEGER, + currentlyactive INTEGER, + created TEXT, + updated TEXT +); + +CREATE TABLE stock ( + id INTEGER PRIMARY KEY, + articleid INTEGER REFERENCES articles (id), + count INTEGER, + created TEXT, + updated TEXT +); + +CREATE TABLE customers ( + id INTEGER PRIMARY KEY, + firstname TEXT, + lastname TEXT, + gender TEXT, + email TEXT, + dateofbirth TEXT, + currentaddressid INTEGER, + created TEXT, + updated TEXT +); + +CREATE TABLE addresses ( + id INTEGER PRIMARY KEY, + customerid INTEGER REFERENCES customers (id), + firstname TEXT, + lastname TEXT, + address1 TEXT, + address2 TEXT, + city TEXT, + zip TEXT, + created TEXT, + updated TEXT +); + +CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + customerid INTEGER REFERENCES customers (id), + ordertimestamp TEXT, + shippingaddressid INTEGER REFERENCES addresses (id), + total REAL, + shippingcost REAL, + created TEXT, + updated TEXT +); + +CREATE TABLE order_positions ( + id INTEGER PRIMARY KEY, + orderid INTEGER REFERENCES orders (id), + articleid INTEGER REFERENCES articles (id), + amount INTEGER, + price REAL, + created TEXT, + updated TEXT +); + +CREATE INDEX idx_articles_product ON articles (productid); +CREATE INDEX idx_stock_article ON stock (articleid); +CREATE INDEX idx_orders_customer ON orders (customerid); +CREATE INDEX idx_order_positions_order ON order_positions (orderid); +CREATE INDEX idx_order_positions_article ON order_positions (articleid); +""" + +# `order` and `customer` are awkward names to ask questions against — one is a +# SQL reserved word, the other reads as a single row — so both are pluralised +# on the way in. `size` and `customer` become `sizeid`/`customerid` to match the +# foreign keys they actually are (upstream's own CREATE scripts name them so). +TABLE_NAMES = {"order": "orders", "customer": "customers", "address": "addresses"} +COLUMN_NAMES = { + "articles": {"size": "sizeid"}, + "order": {"customer": "customerid"}, +} + +# Everything not listed loads as text. +COLUMN_TYPES = { + "id": "int", + "labelid": "int", + "productid": "int", + "colorid": "int", + "sizeid": "int", + "articleid": "int", + "orderid": "int", + "customerid": "int", + "currentaddressid": "int", + "shippingaddressid": "int", + "count": "int", + "amount": "int", + "discountinpercent": "int", + "taxrate": "real", + "originalprice": "money", + "reducedprice": "money", + "price": "money", + "total": "money", + "shippingcost": "money", + "currentlyactive": "bool", +} + +# pg_dump escapes these inside COPY text fields. +_UNESCAPE = {"\\t": "\t", "\\n": "\n", "\\r": "\r", "\\\\": "\\"} -def ensure_sample_db(): - path = Path(os.path.expanduser("~")) / ".bolodb" / "sample_techstore.db" + +# The generated SQLite file lives in the repo's data/ directory, not a user +# home or a persistent volume. It is a cache rebuilt from the vendored dump, so +# it can be thrown away and regenerated at any time. Overridable for tests. +_DATA_DIR = Path( + os.environ.get("BOLODB_DATA_DIR", Path(__file__).resolve().parent.parent / "data") +) + + +def sample_db_path() -> Path: + return _DATA_DIR / f"sample_webshop_v{SAMPLE_VERSION}.db" + + +def ensure_sample_db() -> str: + """Return a connection URL for the sample database, building it if needed.""" + path = sample_db_path() path.parent.mkdir(parents=True, exist_ok=True) - if path.exists(): - return f"sqlite:///{path.as_posix()}" - c = sqlite3.connect(str(path)) - c.executescript( - """ - CREATE TABLE customers(id INTEGER PRIMARY KEY,name TEXT,email TEXT,segment TEXT,country TEXT,created_at TEXT); - CREATE TABLE products(id INTEGER PRIMARY KEY,name TEXT,category TEXT,price REAL,stock INTEGER); - CREATE TABLE orders(id INTEGER PRIMARY KEY,customer_id INTEGER,status TEXT,total_amount REAL,created_at TEXT, - FOREIGN KEY(customer_id) REFERENCES customers(id)); - CREATE TABLE order_items(id INTEGER PRIMARY KEY,order_id INTEGER,product_id INTEGER,quantity INTEGER,unit_price REAL, - FOREIGN KEY(order_id) REFERENCES orders(id),FOREIGN KEY(product_id) REFERENCES products(id)); - CREATE TABLE payments(id INTEGER PRIMARY KEY,order_id INTEGER,method TEXT,amount REAL,paid_at TEXT, - FOREIGN KEY(order_id) REFERENCES orders(id)); - - INSERT INTO customers VALUES - (1,'Ananya Rao','ananya@mail.com','vip','India','2025-11-02'), - (2,'Marcus Hale','marcus@mail.com','business','USA','2026-01-15'), - (3,'Priya Menon','priya@mail.com','vip','India','2025-08-20'), - (4,'Devin Cole','devin@mail.com','consumer','UK','2026-03-10'), - (5,'Sofia Lund','sofia@mail.com','business','Sweden','2025-12-01'), - (6,'Tom Becker','tom@mail.com','consumer','Germany','2026-02-22'); - - INSERT INTO products VALUES - (1,'Aurora 14" Laptop','laptops',1299.00,3), - (2,'Zephyr Phone X','phones',699.00,14), - (3,'NovaBuds Pro','audio',149.00,6), - (4,'PixelView 27"','monitors',449.00,8), - (5,'Glide Wireless Mouse','accessories',49.00,11), - (6,'TypeMaster Keyboard','peripherals',129.00,22), - (7,'PowerHub USB-C','accessories',39.00,85), - (8,'SoundBar S1','audio',199.00,31); - - INSERT INTO orders VALUES - (101,1,'completed',7498.00,'2026-06-02'), - (102,2,'completed',6210.00,'2026-06-01'), - (103,3,'completed',5880.00,'2026-05-28'), - (104,4,'pending',4999.00,'2026-06-03'), - (105,5,'completed',5120.00,'2026-06-04'), - (106,6,'completed',798.00,'2026-05-20'), - (107,1,'completed',3499.00,'2026-04-12'), - (108,2,'cancelled',1299.00,'2026-03-01'), - (109,3,'completed',449.00,'2026-06-05'), - (110,4,'completed',149.00,'2026-05-30'); - - INSERT INTO order_items VALUES - (1,101,1,1,1299),(2,101,2,1,699),(3,102,6,1,129),(4,103,3,2,149), - (5,104,1,1,1299),(6,105,4,1,449),(7,106,5,2,49),(8,107,8,1,199), - (9,108,6,1,129),(10,109,4,1,449),(11,110,3,1,149); - - INSERT INTO payments VALUES - (1,101,'card',7498.00,'2026-06-02'), - (2,102,'paypal',6210.00,'2026-06-01'), - (3,103,'card',5880.00,'2026-05-28'), - (5,105,'bank',5120.00,'2026-06-04'), - (6,106,'card',798.00,'2026-05-20'); - """ - ) - c.commit() - c.close() - print(f"Sample database created: {path}") + if not path.exists(): + _build(path) + _remove_stale_samples(path) return f"sqlite:///{path.as_posix()}" +def _build(path: Path) -> None: + """Load the vendored dump into a fresh SQLite file at `path`. + + Built into a temporary file in the same directory and moved into place, so a + build that dies halfway (or two workers racing on first connect) can never + leave a half-loaded database behind for the next request to read. + """ + fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), suffix=".db") + os.close(fd) + tmp = Path(tmp_name) + try: + conn = sqlite3.connect(str(tmp)) + try: + conn.executescript(SCHEMA) + for table, columns, rows in _read_dump(): + placeholders = ", ".join("?" * len(columns)) + quoted = ", ".join(f'"{c}"' for c in columns) + conn.executemany( + f'INSERT INTO "{table}" ({quoted}) VALUES ({placeholders})', rows + ) + conn.commit() + finally: + conn.close() + os.replace(tmp, path) + logger.info("Sample database created: %s", path) + except Exception: + tmp.unlink(missing_ok=True) + raise + + +def _remove_stale_samples(current: Path) -> None: + """Delete sample files left by earlier versions of the sample dataset.""" + for old in current.parent.glob("sample_*.db"): + if old != current: + try: + old.unlink() + except OSError as e: + logger.warning("Could not remove old sample database %s: %s", old, e) + + +def _read_dump(): + """Yield (table, columns, rows) for each COPY block in the vendored dump.""" + with gzip.open(DUMP_PATH, "rt", encoding="utf-8") as fh: + table = None + columns: list[str] = [] + converters: list[str] = [] + rows: list[tuple] = [] + for line in fh: + line = line.rstrip("\n") + if line.startswith("COPY "): + header = line[len("COPY ") :] + source_table = header[: header.index(" (")].strip() + source_columns = [ + c.strip() + for c in header[header.index("(") + 1 : header.rindex(")")].split( + "," + ) + ] + renames = COLUMN_NAMES.get(source_table, {}) + table = TABLE_NAMES.get(source_table, source_table) + columns = [renames.get(c, c) for c in source_columns] + converters = [COLUMN_TYPES.get(c, "text") for c in columns] + rows = [] + elif line == "\\.": + yield table, columns, rows + table = None + elif table is not None: + values = line.split("\t") + rows.append( + tuple( + _convert(v, t) for v, t in zip(values, converters, strict=True) + ) + ) + + +def _convert(value: str, kind: str): + if value == "\\N": + return None + if kind == "text": + return _unescape(value) + if kind == "bool": + return 1 if value == "t" else 0 + if kind == "int": + return int(value) + if kind == "money": + # pg's `money` type dumps as `$1,234.56`, negatives as `-$1,234.56`. + cleaned = value.replace("$", "").replace(",", "") + return float(cleaned) if cleaned else None + return float(value) + + +def _unescape(value: str) -> str: + if "\\" not in value: + return value + out = [] + i = 0 + while i < len(value): + pair = value[i : i + 2] + if pair in _UNESCAPE: + out.append(_UNESCAPE[pair]) + i += 2 + else: + out.append(value[i]) + i += 1 + return "".join(out) + + if __name__ == "__main__": print(ensure_sample_db()) diff --git a/backend/sample_db/build_sample_dump.py b/backend/sample_db/build_sample_dump.py new file mode 100644 index 00000000..28cec46c --- /dev/null +++ b/backend/sample_db/build_sample_dump.py @@ -0,0 +1,135 @@ +"""Regenerate `webshop.dump.gz` from the upstream PostgreSQL sample database. + +The sample store BoloDB ships is a trimmed copy of +https://github.com/JannikArndt/PostgreSQLSampleDatabase — a webshop with 1,000 +customers, 1,000 products, ~17,700 articles and 2,000 orders. Upstream ships +`pg_dump` files that only Postgres can restore; BoloDB needs a zero-setup SQLite +file, so this script keeps the parts that carry data (the `COPY … FROM stdin` +blocks) and drops everything Postgres-specific around them. + +Run it only when refreshing the vendored data: + + python backend/sample_db/build_sample_dump.py /path/to/PostgreSQLSampleDatabase + +The output, `backend/sample_db/webshop.dump.gz`, is read at runtime by +`backend.sample_data.ensure_sample_db`. +""" + +import gzip +import sys +from datetime import datetime, timedelta +from pathlib import Path + +OUT = Path(__file__).parent / "webshop.dump.gz" + +# Load order respects the foreign keys between tables. +SOURCES = [ + ("colors", "create.sql"), + ("sizes", "create.sql"), + ("labels", "labels.sql"), + ("products", "products.sql"), + ("articles", "articles.sql"), + ("stock", "stock.sql"), + ("customer", "customer.sql"), + ("address", "address.sql"), + ("order", "order.sql"), + ("order_positions", "order_positions.sql"), +] + +# `icon` is a bytea blob of brand logos — megabytes of hex that no question in a +# SQL demo ever asks about. +DROP_COLUMNS = {"labels": {"icon"}} + + +def _copy_block(text: str, table: str): + """Return (columns, rows) for a table's `COPY … FROM stdin` block.""" + needle_variants = ( + f"COPY webshop.{table} (", + f'COPY webshop."{table}" (', + ) + for needle in needle_variants: + start = text.find(needle) + if start != -1: + break + else: + raise SystemExit(f"No COPY block for {table}") + + header_end = text.index("\n", start) + header = text[start:header_end] + columns = [ + c.strip() for c in header[header.index("(") + 1 : header.rindex(")")].split(",") + ] + + body = text[header_end + 1 :] + body = body[: body.index("\n\\.")] + rows = [line.split("\t") for line in body.split("\n") if line] + return columns, rows + + +def main() -> None: + if len(sys.argv) != 2: + raise SystemExit(__doc__) + data_dir = Path(sys.argv[1]) / "data" + + cache: dict[str, str] = {} + tables = [] + latest = None + + for table, filename in SOURCES: + if filename not in cache: + cache[filename] = (data_dir / filename).read_text(encoding="utf-8") + columns, rows = _copy_block(cache[filename], table) + + drop = DROP_COLUMNS.get(table, set()) + keep = [i for i, c in enumerate(columns) if c not in drop] + columns = [columns[i] for i in keep] + rows = [[r[i] for i in keep] for r in rows] + + for row in rows: + for value in row: + stamp = _parse_timestamp(value) + if stamp and (latest is None or stamp > latest): + latest = stamp + + tables.append((table, columns, rows)) + + # The upstream data was generated in 2018. Left alone, every "last month" + # or "this year" question against the demo returns nothing, which reads as a + # broken product rather than old data. Slide the whole history forward so the + # newest row lands on today, keeping every interval between rows intact. + shift = timedelta(0) + if latest is not None: + shift = datetime.now().replace(microsecond=0) - latest + + with gzip.open(OUT, "wt", encoding="utf-8", compresslevel=9) as fh: + for table, columns, rows in tables: + fh.write(f"COPY {table} ({', '.join(columns)}) FROM stdin;\n") + for row in rows: + fh.write("\t".join(_shift(v, shift) for v in row) + "\n") + fh.write("\\.\n") + + total = sum(len(rows) for _, _, rows in tables) + print( + f"Wrote {OUT} — {len(tables)} tables, {total} rows, shifted by {shift.days} days" + ) + + +def _parse_timestamp(value: str): + """Parse a pg_dump `timestamp with time zone` literal, or return None.""" + if len(value) < 19 or value[4] != "-" or value[7] != "-" or value[10] != " ": + return None + try: + return datetime.strptime(value[:19], "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + + +def _shift(value: str, shift: timedelta) -> str: + stamp = _parse_timestamp(value) + if stamp is None: + return value + return (stamp + shift).strftime("%Y-%m-%d %H:%M:%S") + + +if __name__ == "__main__": + main() diff --git a/backend/sample_db/webshop.dump.gz b/backend/sample_db/webshop.dump.gz new file mode 100644 index 00000000..52927c53 Binary files /dev/null and b/backend/sample_db/webshop.dump.gz differ diff --git a/design.md b/design.md deleted file mode 100644 index 88c1ca1d..00000000 --- a/design.md +++ /dev/null @@ -1,116 +0,0 @@ -# BoloDB — Hallmark Design Audit - -**Date:** 2026-07-16 -**Branch:** hallmark-redesign - -## Anti-Pattern Findings - -| ID | Severity | Pattern | Status | -|---|---|---|---| -| C1 | Critical | AI-default nav (wordmark-left, links-centred, CTA-right) | Fixed — N5 floating pill | -| C2 | Critical | Centred hero layout | Fixed — asymmetric 2-column | -| C3 | Critical | Centred everything (eyebrows + centred text) | Fixed — eyebrows removed, FinalCta left-aligned | -| C4 | Critical | 3-column equal feature grid | Fixed — 2-col + hero card | -| C5 | Critical | AI-default footer (social icons, multi-column) | Fixed — Ft6 letter close | -| C6 | Critical | Fake browser chrome in demo | Fixed — minimal demo-top with badge | -| C7 | Critical | Token improvisation (raw hex in components) | Fixed — lifted to tokens | -| C8 | Critical | Invented metrics (128940) | Fixed — replaced with — placeholder | -| M3 | Major | Centred text in sections | Fixed — left-aligned where needed | -| M4 | Major | GSAP scroll animations (performance) | Fixed — removed from Pipeline, FinalCta, TrustEngine | -| M5 | Major | Eyebrow labels on every section | Fixed — removed all eyebrows | - -## Changes Applied - -### Commit 1: `fix(routing)` -- `connect/+page.svelte`: Shows "currently connected" banner with switch/disconnect options -- `chat/+page.svelte`: Shows "No database connected" empty state instead of blank flash -- `dashboard/+page.svelte`: Shows empty state with "Connect a database" CTA -- `onboard/+page.svelte`: Added loading state while checking connection -- `Navbar.svelte`: Conditionally shows "Switch DB" vs "Connect" based on `appState.dbInfo` - -### Commit 2: `fix(nav)` -- `MarketingNav.svelte`: Full rewrite from full-width sticky nav to floating pill at bottom of viewport -- Pill appears after scrolling past hero (200px threshold) -- Frosted glass backdrop-filter, logo · divider · links · divider · actions -- Mobile: links hidden, full-width pill at bottom - -### Commit 3: `fix(hero)` -- `Hero.svelte`: Replaced centred single-column with asymmetric 2-column grid (1.2fr + 0.8fr) -- Removed eyebrow label, removed `min-height: 90vh`, content-based height -- Mobile: collapses to centred single column at 768px - -### Commit 4: `fix(components)` -- `TrustEngine.svelte`: Break 3-col equal grid → 2-col bottom + full-width hero card -- `LiveDemo.svelte`: Remove fake browser chrome, replace raw hex with tokens, remove eyebrow -- `Flywheel.svelte`: Replace invented metric 128940 with — placeholder, remove eyebrow - -### Commit 5: `fix(footer)` -- `Footer.svelte`: Rewrite as Ft6 — brand left, links below, reassurance right -- `Pipeline.svelte`: Remove eyebrow, remove GSAP spine scroll animation -- `Integrations.svelte`: Remove eyebrow, remove use:reveal -- `FinalCta.svelte`: Left-align panel, remove GSAP scroll animation, remove magnetic action - -### Commit 6: `fix(alembic)` -- Rename duplicate revision 0003 → 0004 for password_reset_tokens migration - -### Commit 7: `fix: reduce card padding, add missing sampleSQL` -- Pipeline cards: 32px → 24px padding -- TrustEngine cards: 36px → 28px padding -- TrustEngine flip card: define sampleSQL (was undefined) - -### Commit 8: `feat: add privacy and terms pages` -- Create `/privacy` and `/terms` routes with legal content -- Footer: add Privacy and Terms links -- Layout: add legal routes to hidden navbar paths - -## Design Tokens - -| Token | Value | Usage | -|---|---|---| -| `--brand` | `#1b9e6b` | Primary accent | -| `--bg` | `#eef1f0` | Page background | -| `--surface` | `#f6f8f7` | Card background | -| `--ink` | `#1a1d1c` | Primary text | -| `--muted` | `#5c6360` | Secondary text | -| `--faint` | `#8c918e` | Tertiary text | -| `--border` | `#d4dbd8` | Borders | -| `--radius-sm` | `8px` | Small radius | -| `--radius-md` | `12px` | Medium radius | -| `--radius-lg` | `16px` | Large radius | -| `--font-display` | `Hanken Grotesk` | Headings | -| `--font-mono` | `JetBrains Mono` | Code/labels | - -## Marketing Page Architecture - -```text -MarketingLayout -├── MarketingNav (floating pill, bottom) -├── Hero (left-biased 2-col) -├── LiveDemo (minimal panel) -├── Pipeline (3-step cards) -├── TrustEngine (2-col + hero card) -├── Flywheel (diagram + stats) -├── Integrations (DB grid + connection strings) -├── FinalCta (left-aligned panel) -└── Footer (Ft6 letter close) -``` - -## Files Modified - -- `frontend/src/lib/marketing/MarketingNav.svelte` -- `frontend/src/lib/marketing/Hero.svelte` -- `frontend/src/lib/marketing/LiveDemo.svelte` -- `frontend/src/lib/marketing/Pipeline.svelte` -- `frontend/src/lib/marketing/TrustEngine.svelte` -- `frontend/src/lib/marketing/Flywheel.svelte` -- `frontend/src/lib/marketing/Integrations.svelte` -- `frontend/src/lib/marketing/FinalCta.svelte` -- `frontend/src/lib/marketing/Footer.svelte` -- `frontend/src/routes/privacy/+page.svelte` -- `frontend/src/routes/terms/+page.svelte` -- `frontend/src/routes/connect/+page.svelte` -- `frontend/src/routes/chat/+page.svelte` -- `frontend/src/routes/dashboard/+page.svelte` -- `frontend/src/routes/onboard/+page.svelte` -- `frontend/src/lib/components/ui/Navbar.svelte` -- `backend/alembic/versions/0004_add_password_reset_tokens.py` diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..616a63cd --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,51 @@ +# ────────────────────────────────────────────── +# BoloDB · Local development overrides +# ────────────────────────────────────────────── +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build +# +# NOT auto-loaded (unlike docker-compose.override.yml), so Coolify / +# production deploys that use only docker-compose.yml get the static +# nginx image without a Vite bind-mount that empties /app. +# ────────────────────────────────────────────── + +services: + backend: + ports: + - "127.0.0.1:4322:4321" + + # Vite HMR for local UI work — overrides the production static nginx build + frontend: + build: + context: ./frontend + dockerfile: DOCKERFILE.dev + container_name: bolodb-frontend + volumes: + - ./frontend:/app + - /app/node_modules + ports: + - "127.0.0.1:5174:5173" + environment: + - BACKEND_URL=http://backend:4321 + - PUBLIC_POSTHOG_PROJECT_TOKEN=${PUBLIC_POSTHOG_PROJECT_TOKEN} + - PUBLIC_POSTHOG_HOST=${PUBLIC_POSTHOG_HOST:-https://us.i.posthog.com} + - VITE_SUPABASE_URL=${SUPABASE_URL:?SUPABASE_URL must be set} + - VITE_SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY:?SUPABASE_ANON_KEY must be set} + depends_on: + backend: + condition: service_healthy + networks: + - bolodb-net + + nginx: + build: + context: ./nginx + dockerfile: DOCKERFILE.dev + container_name: bolodb-nginx + ports: + - "127.0.0.1:8080:80" + depends_on: + frontend: + condition: service_started + backend: + condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 62079c6c..dae35362 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,86 +1,69 @@ -# ────────────────────────────────────────────── -# BoloDB · Docker Compose -# ────────────────────────────────────────────── -# Usage: -# docker compose up --build # build & start -# docker compose up --build -d # detached -# docker compose down # stop & remove -# ────────────────────────────────────────────── - services: # ── Backend: FastAPI / Uvicorn ── backend: build: context: . dockerfile: backend/DOCKERFILE - container_name: bolodb-backend restart: unless-stopped - ports: - - "4321:4321" + expose: + - "4321" volumes: - - bolodb-data:/home/bolodb/.bolodb - - ./data:/app/data + # The generated sample database is a regenerable cache; the volume keeps + # it across restarts so it isn't rebuilt each time, but nothing here is + # state that has to survive — all real data lives in Postgres. + - bolodb-app-data:/app/data environment: - JWT_SECRET=${JWT_SECRET:?JWT_SECRET must be set} + # Stable secret that encrypts saved connection URLs. Never written to + # disk, so it must be supplied here and kept constant across deploys. + - RECENT_CONNECTIONS_KEY=${RECENT_CONNECTIONS_KEY:?RECENT_CONNECTIONS_KEY must be set} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:?OPENROUTER_API_KEY must be set} - DATABASE_URL=${DATABASE_URL:? Database URL must be set} - PYTHONUNBUFFERED=1 - RUNNING_IN_DOCKER=true - # - OLLAMA_URL=http://host.docker.internal:11434 + - SKIP_DB_LOCK=true - SUPABASE_URL=${SUPABASE_URL:?SUPABASE_URL must be set} - SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY:?SUPABASE_ANON_KEY must be set} - SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET:?SUPABASE_JWT_SECRET must be set} - MYEMAILVERIFIER_API_KEY=${MYEMAILVERIFIER_API_KEY} - RESEND_API_KEY=${RESEND_API_KEY} + healthcheck: + test: ["CMD-SHELL", "python -c \"import httpx; httpx.get('http://localhost:4321/api/health', timeout=3).raise_for_status()\" || exit 1"] + interval: 10s + timeout: 5s + retries: 3 + # Migrations run before the server binds, and a database that needs the + # whole history takes longer than a few seconds — don't call the + # container unhealthy while it is still migrating. + start_period: 90s extra_hosts: - "host.docker.internal:host-gateway" networks: - bolodb-net - # ── Frontend: Svelte (Vite) dev server ── - frontend: - build: - context: ./frontend - dockerfile: DOCKERFILE.dev - container_name: bolodb-frontend - volumes: - - ./frontend:/app - - /app/node_modules - ports: - - "5173:5173" - environment: - - BACKEND_URL=http://backend:4321 - - PUBLIC_POSTHOG_PROJECT_TOKEN=${PUBLIC_POSTHOG_PROJECT_TOKEN} - - PUBLIC_POSTHOG_HOST=${PUBLIC_POSTHOG_HOST:-https://us.i.posthog.com} - - VITE_SUPABASE_URL=${SUPABASE_URL:?SUPABASE_URL must be set} - - VITE_SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY:?SUPABASE_ANON_KEY must be set} - depends_on: - backend: - condition: service_healthy - networks: - - bolodb-net - - # ── Nginx: Reverse proxy + Vite dev server ── + # ── Nginx: static frontend + /api reverse proxy ── nginx: build: - context: ./nginx - dockerfile: DOCKERFILE - container_name: bolodb-nginx + context: . + dockerfile: nginx/DOCKERFILE + args: + PUBLIC_POSTHOG_PROJECT_TOKEN: ${PUBLIC_POSTHOG_PROJECT_TOKEN:-} + PUBLIC_POSTHOG_HOST: ${PUBLIC_POSTHOG_HOST:-https://us.i.posthog.com} restart: unless-stopped expose: - - '80' + - "8080" + labels: # <--- ADD THIS + - "traefik.http.services.${COOLIFY_CONTAINER_NAME:-nginx}.loadbalancer.server.port=8080" depends_on: - frontend: - condition: service_started backend: condition: service_healthy networks: - bolodb-net volumes: - bolodb-data: bolodb-postgres-data: bolodb-postgres-wal: + bolodb-app-data: networks: bolodb-net: diff --git a/docs/01-what-is-bolodb.md b/docs/01-what-is-bolodb.md index 4cb7ee24..cad7aa87 100644 --- a/docs/01-what-is-bolodb.md +++ b/docs/01-what-is-bolodb.md @@ -2,102 +2,94 @@ **BoloDB lets you talk to your database in plain English.** -You connect a database (PostgreSQL, MySQL, SQLite, SQL Server, DuckDB), and -then instead of writing SQL — the technical language databases understand — -you just ask questions the way you'd ask a colleague: +It is a multi-tenant web application. You sign up for an account, create or join a workspace, connect a database (PostgreSQL, MySQL, SQLite, SQL Server, DuckDB), and then instead of writing SQL you just ask questions the way you'd ask a colleague: > "How many orders were completed last month?" > > "Which product category brings in the most revenue?" -BoloDB translates the question into SQL using OpenRouter AI, runs it -against your database, and shows you: +BoloDB translates the question into SQL using OpenRouter AI (deepseek/deepseek-v4-flash), runs it against your database, and shows you: -1. **The answer** — a table of results (and the raw SQL, one tap away). -2. **A restatement** — one plain sentence describing what was actually - computed, e.g. *"Count of orders with status 'completed' created in the - previous calendar month."* This is how you check the AI understood you. -3. **A confidence badge** — High / Medium / Low, based on real signals - (did the query run? does it match something you verified before?), not on - the AI's self-assessment. +1. **The answer** — a table of results (and the raw SQL, one tap away). Many results also show a **chart** — the AI decides whether a bar, line, area, pie, or big-number visualization fits best. +2. **A restatement** — one plain sentence describing what was actually computed. This is how you check the AI understood you. +3. **A confidence badge** — High / Medium / Low, based on real signals (did the query run? does it match something you verified before?), not on the AI's self-assessment. ## The three promises ### 1. Your data is safe -- BoloDB opens your database in **read-only mode**. Every query is checked - twice — once by parsing it into a syntax tree and rejecting anything that - isn't a plain SELECT (code: `backend/app/database.py` → - `_readonly_violation()`), and we recommend connecting with a read-only - database account as a third layer. -- When the AI is asked to write SQL, it is sent your **question** plus the - context BoloDB builds around it: the **database structure** (table and - column names, keys), a few **sample values and sample rows** per table (so - the AI matches your wording to real data), your confirmed **glossary - terms**, previously **verified question→SQL examples**, and the last couple - of conversation turns. The prompt **never** includes bulk table contents, - query results, or credentials — your OpenRouter API key is used only as the - request's authentication header. See chapter 3 for the exact prompt - contents. +- BoloDB opens your database in **read-only mode**. Every query is checked twice — once by parsing it into a syntax tree and rejecting anything that isn't a plain SELECT (code: `backend/app/database.py` → `_readonly_violation()`), and we recommend connecting with a read-only database account as a third layer. +- Database URLs are validated against an allowlist to **prevent SSRF attacks** — no localhost, no loopback, no cloud metadata endpoints (`backend/app/database.py` → `_validate_db_url()`). +- When the AI is asked to write SQL, it is sent your **question** plus context: the **database structure** (table and column names, keys), a few **sample values and sample rows** per table, your confirmed **glossary terms**, the **semantic catalog** (metrics, joins, synonyms, value maps), previously **verified question→SQL examples**, and the last couple of conversation turns. The prompt **never** includes bulk table contents, query results, or credentials. See chapter 3 for the exact prompt contents. +- Connection credentials saved to the workspace are **encrypted at rest** using Fernet symmetric encryption (`backend/app/pgdatabase/connections.py`). ### 2. You can trust the answers — and check them -Non-technical users can't audit SQL, so BoloDB gives you tools that don't -require reading code: +Non-technical users can't audit SQL, so BoloDB gives you tools that don't require reading code: -- The **restatement** (above) tells you in English what was computed. -- The **Explain** feature can translate any SQL back into plain English - (code: `backend/app/llm.py` → `explain_sql()`, exposed at the - `/api/explain` endpoint). -- The **confidence badge** is computed from signals, not vibes (code: - `backend/app/schema_link.py` → `compute_confidence()`). -- Broken SQL never silently produces a wrong answer: every generated query is - validated against your real schema before it runs, and automatically - repaired if it fails (code: `backend/app/repair.py`). +- The **restatement** tells you in English what was computed. +- The **chart** gives you a visual check — if the bar chart looks reasonable, the SQL probably is too. +- The **Explain** feature can translate any SQL back into plain English (code: `backend/app/llm.py` → `explain_sql()`, exposed at the `/api/explain` endpoint). +- The **confidence badge** is computed from signals, not vibes (code: `backend/app/schema_link.py` → `compute_confidence()`). +- Broken SQL never silently produces a wrong answer: every generated query is validated against your real schema before it runs, and automatically repaired if it fails (code: `backend/app/repair.py`). ### 3. It gets better the more you use it -When you click **"Yes, correct"** on an answer, that question-and-SQL pair is -saved into a local knowledge base (code: `backend/app/knowledge.py`). The next -time you — or a colleague — asks something similar, BoloDB shows the AI your -verified example, which is the single strongest accuracy boost we have. Your -trust level climbs from *Supervised* → *Assisted* → *Trusted* as you verify -more answers. - -## What happens the first time you connect (onboarding) - -1. **Set up the AI** — paste a free OpenRouter API key - (get one at https://aistudio.google.com/app/api-keys). Screen: - `frontend/src/lib/components/ConnectScreen.svelte`. -2. **Connect a database** — or click *"Try with sample data"* to explore a - realistic demo store database (`backend/sample_data.py`). -3. **Confirm business terms** — BoloDB reads your schema and guesses the 3 - most important business terms ("revenue", "active customer"...) and what - they mean in *your* database. You confirm or correct them. (Code: - `backend/app/llm.py` → `generate_glossary()`.) -4. **Verify starter questions** — BoloDB proposes 3 example questions with - answers; each one you confirm seeds the knowledge base. (Code: - `backend/app/llm.py` → `generate_starters()`.) - -After onboarding you land in the chat screen -(`frontend/src/lib/components/AskScreen.svelte`) and just ask. +When you click **"Yes, correct"** on an answer, that question-and-SQL pair is saved into the workspace's knowledge base in PostgreSQL (code: `backend/app/pgdatabase/knowledge.py`). The next time you — or a teammate — asks something similar, BoloDB finds your verified example and includes it in the prompt, which is the single strongest accuracy boost we have. + +## Accounts, workspaces, and permissions + +BoloDB is a **multi-tenant** application: + +- **Sign up** with email/password or Google OAuth. +- Create or join **workspaces**. Each workspace has its own databases, knowledge base, dashboards, and members. +- Each member has a **role**: *Owner*, *Admin*, or *Member*. Roles control fine-grained **permissions**: who can connect databases, who can manage the catalog, who can create dashboards, and more. See chapter 10 for the full permission matrix. + +## What happens the first time you log in + +1. **Sign up** — create an account (or continue with Google). +2. **Create or join a workspace** — workspaces are where your databases and knowledge live. +3. **Set up the AI** — paste an OpenRouter API key (get one at https://openrouter.ai/keys). Or if the workspace admin already configured it, skip this step. +4. **Connect a database** — or click *"Try with sample data"* to explore a realistic demo store. +5. **Confirm business terms** — BoloDB reads your schema and suggests business terms and their meanings. You confirm or correct them (`backend/app/llm.py` → `suggest_catalog()`). +6. **Verify starter questions** — BoloDB proposes example questions with answers; each one you confirm seeds the knowledge base (`backend/app/llm.py` → `generate_starters()`). + +After onboarding you land in the chat screen and just ask. ## The cast of components (30-second architecture) ```text Browser (SvelteKit frontend, frontend/src) - │ HTTP calls to /api/... + │ HTTP calls to /api/... (JWT cookie auth) ▼ FastAPI backend (backend/app) │ - ├── controllers/query.py ── the question→answer pipeline (the heart) - ├── llm.py ──────────────── the ONLY file that talks to OpenRouter - ├── schema_link.py ──────── picks which tables the AI sees - ├── sqlvalidate.py ──────── checks generated SQL against the real schema - ├── repair.py ───────────── auto-fixes SQL that fails, with AI feedback - ├── database.py ─────────── connects to YOUR database, read-only execution - ├── knowledge.py ────────── remembers verified answers + glossary (SQLite) - └── config.py ───────────── settings: OpenRouter key + model (~/.bolodb/) + ├── controllers/query.py ─── the question→answer pipeline (the heart) + ├── llm.py ───────────────── the ONLY file that talks to OpenRouter + ├── schema_link.py ───────── picks which tables the AI sees + ├── sqlvalidate.py ───────── checks generated SQL against the real schema + ├── repair.py ────────────── auto-fixes SQL that fails, with AI feedback + ├── database.py ──────────── connects to YOUR database, read-only execution + ├── pgdatabase/knowledge.py ─ remembers verified answers + glossary (PostgreSQL) + └── auth routes ──────────── JWT auth, Google OAuth, email verification + PostgreSQL ────────── users, workspaces, knowledge, dashboards, history +``` + +### Services layer + +```text + Workspace ────────── has members with roles and permissions + │ + ├── Database connection ─── keyed per workspace+db_id + │ └── Schema introspection → schema_link → prompt + │ + ├── Knowledge (PostgreSQL) ─ verified Q&A, glossary, catalog + │ + ├── Saved queries ───────── queries saved for reuse + │ + ├── Dashboards ──────────── panels with ECharts visualizations + │ + └── Activity log ────────── append-only audit trail ``` The next chapter walks a single question through this whole pipeline with diff --git a/docs/02-how-a-question-becomes-an-answer.md b/docs/02-how-a-question-becomes-an-answer.md index 91cf7ec1..d747b36c 100644 --- a/docs/02-how-a-question-becomes-an-answer.md +++ b/docs/02-how-a-question-becomes-an-answer.md @@ -1,146 +1,79 @@ # 2. How a question becomes an answer -This is the most important chapter: it follows **one question** through the -entire system, in order, with the exact code location for every step. If you -ever wonder "where does X happen?" or "why did I get this answer?", start here. +This chapter follows **one question** through the entire BoloDB system, in order, with exact code locations for every step. -Our example question, asked against a demo e-commerce database: +Our example question: > **"How much revenue did we make from laptops last month?"** +--- + ## Step 0 — The browser sends the question -You type into the chat screen and press *Ask*. - -- **Where:** `frontend/src/lib/components/AskScreen.svelte` → the `ask()` - function sends `POST /api/query` with the question, plus the previous couple - of question/SQL pairs (so follow-up questions like "now just 2023" work). -- The request lands in `backend/app/routes/query.py` → `query()`, which calls - the pipeline: `backend/app/controllers/query.py` → `run_query()`. - -Everything below happens inside `run_query()`. The step numbers here match -the numbered comments in that function — you can read them side by side. - -## Step 1 — Look up what BoloDB already knows - -Two lookups against the local knowledge base (a small SQLite file at -`~/.bolodb/knowledge.db`, managed by `backend/app/knowledge.py`): - -1. **Glossary** — the business terms you confirmed during onboarding. - For our question, the glossary might contain - `revenue = sum of total_amount on completed orders`. - *Code:* `KnowledgeBase.get_glossary()`. -2. **Similar verified answers** — up to 3 previously-confirmed - question→SQL pairs that resemble this question, found with a text - similarity score (word overlap + sequence similarity). - *Code:* `KnowledgeBase.retrieve_similar()`, scoring in `_similarity()`. - -Both are later placed into the AI prompt: the glossary as ground-truth term -definitions, the verified answers as worked examples to imitate. - -## Step 2 — Pick which tables the AI gets to see (schema linking) - -A real database may have 40+ tables. Sending them all would cost more AND -confuse the AI. So BoloDB scores every table against the question and keeps -only the relevant ones. - -- **Where:** `backend/app/schema_link.py` → `link_relevant_tables()`. -- For our question, tables named `orders`, `order_items`, `products` score - high (name/column matches on "revenue"→glossary→"total_amount", - "laptops" matches a known value of `products.category`), and tables like - `employees` or `audit_log` score zero and are dropped. -- Foreign keys are then followed so the chosen tables can be JOINed - (details in [chapter 4](04-schema-linking.md)). -- How many tables are allowed depends on the OpenRouter model configured — - `model_budget()` in the same file. -- On big databases (30+ tables), a cheap AI pre-pass shortlists candidate - tables from a names-only catalog first — "two-stage linking", chapter 4. - -The chosen tables are rendered into a compact text form the AI reads — -`compact_schema()` produces lines like: - -```text -orders(id PK, customer_id->customers.id, status[completed,pending,cancelled], total_amount, created_at) ~38104 rows -``` - -## Step 3 — Generate, validate, execute — and self-repair if needed - -This is the loop that actually produces the answer. **Where:** -`backend/app/repair.py` → `run_repair_loop()`, wired up in `run_query()`. - -Each iteration (at most 3, and no new attempt after 60 seconds): - -1. **Generate.** The OpenRouter AI is asked for the SQL. - *Code:* `backend/app/llm.py` → `generate_sql()`; the exact prompt text is - assembled in `build_sql_system_prompt()` in the same file — open it to see - precisely what the AI is told. The AI must reply in a fixed JSON shape - (`SQL_SCHEMA`): the SQL, a plain-English restatement, and any assumptions - it made. -2. **Validate.** Before anything touches your database, the SQL is parsed - and every table and column it mentions is checked against your real - schema. A hallucinated column like `orders.revenue` is caught here. - *Code:* `backend/app/sqlvalidate.py` → `validate_sql()`. -3. **Execute.** The query runs against your database — read-only, with the - safety guard described in [chapter 5](05-safety-validation-and-self-repair.md). - *Code:* `backend/app/database.py` → `DatabaseManager.execute()`. -4. **Repair (only if 2 or 3 failed).** The specific error — "Unknown column: - 'revenue' does not exist in table 'orders'" or the database's own error - message — is appended to the question and the loop starts over. The AI - sees exactly what broke and fixes it. *Code:* the feedback text is built - by `_feedback()` in `repair.py`. And if the failed SQL referenced a real - table that step 2 didn't select, that table is added to the prompt for - the retry ("schema-retry" — chapter 4). - -The response records how many attempts were needed (`attempts` field) and -whether a repair happened (`repaired`) — useful when debugging. - -## Step 4 — Score confidence, log, and answer - -- **Confidence** is computed from real signals — did the query run, did it - return rows, and how closely does the question match something you already - verified. *Code:* `backend/app/schema_link.py` → `compute_confidence()`. - The exact thresholds are in [chapter 6](06-learning-trust-and-confidence.md). -- The query is **logged** to the session log (`backend/app/logbook.py`) and - the per-user history (MongoDB, `backend/app/mongodatabase.py`, called from - `routes/query.py`). -- The JSON response goes back to the browser, which renders it as an answer - card: `frontend/src/lib/components/AnswerCard.svelte`. - -## Step 5 — You give feedback (optional but powerful) - -If you click **"Yes, correct"**, the browser calls `POST /api/feedback` → -`backend/app/controllers/query.py` → `feedback()`, which saves the -question+SQL into the knowledge base (`KnowledgeBase.add_verified()`). -From now on, similar questions retrieve this example in Step 1 and show -higher confidence in Step 4. This loop — ask, verify, improve — is the -product's core flywheel. - -## The full picture - -```text - Question ─▶ [1] knowledge lookup knowledge.py - [2] schema linking schema_link.py - [3] generate ◀────┐ llm.py (OpenRouter) - validate │ sqlvalidate.py - execute │ database.py - (repair) ─────┘ repair.py - [4] confidence + log schema_link.py, logbook.py - Answer ◀── JSON: sql, restatement, rows, confidence, attempts -``` - -## What the response actually contains - -`POST /api/query` returns (shape assembled at the end of `run_query()`): - -| Field | Meaning | -|---|---| -| `sql` | The SQL that was run (empty if generation failed entirely) | -| `restatement` | One plain sentence describing what the query computes | -| `assumptions` | Assumptions the AI made (e.g. "interpreted 'last month' as the previous calendar month") | -| `columns`, `rows` | The results (max 500 rows; `truncated` is true if capped) | -| `confidence`, `confidence_reason` | High/Medium/Low + why | -| `based_on_verified` | Whether a previously verified answer informed this one | -| `tables_used` | Which tables schema linking selected | -| `attempts`, `repaired` | How many generation attempts were needed; whether a self-repair happened | -| `execution_error` | Present only if the final attempt still failed | -| `query_id` | Handle for feedback ("Yes, correct" / "Something's wrong") | +You type into the chat screen (`frontend/src/lib/components/AskScreen.svelte`) and submit. + +- **Request**: Sends `POST /api/query` containing: + - Natural language question + - Conversation context (previous question/SQL pairs for follow-up resolution) + - `X-Workspace-Id` and `X-Db-Id` headers (Workspace and Database selection) + - JWT authentication cookie (`backend/app/dependencies.py` → `get_current_user`) +- **Route**: `backend/app/routes/query.py` → `query()`, which invokes `backend/app/controllers/query.py` → `run_query()`. + +--- + +## Step 1 — Knowledge Lookup & Semantic Layer Injection + +BoloDB queries the PostgreSQL workspace knowledge base (`backend/app/pgdatabase/knowledge.py` → `KnowledgeService`): + +1. **Business Glossary**: Confirmed business term definitions (e.g., `revenue = sum of total_amount on completed orders`). +2. **Semantic Layer Catalog**: Active metrics (`CatalogMetric`), join paths (`CatalogJoin`), synonyms (`CatalogSynonym`), and value mappings (`CatalogValueMapping`). +3. **Verified Answer Examples**: Up to 3 similar verified question→SQL pairs retrieved via similarity scoring (`retrieve_similar()`). + +--- + +## Step 2 — Schema Linking (Table Selection) + +To optimize cost and avoid confusing the AI, BoloDB filters the database schema down to only the relevant tables. + +- **Location**: `backend/app/schema_link.py` → `link_relevant_tables()`. +- Tables matching "revenue" (via glossary `total_amount`), "laptops" (via product category value match), and "orders" are selected. +- Foreign keys and junction tables (e.g. `order_items`) are expanded (`expand_linked_tables()`). +- On large databases (30+ tables), a cheap pre-pass shortlists candidates (`llm.py` → `shortlist_tables()`). +- Selected tables are formatted into compact text representations via `compact_schema()`. + +--- + +## Step 3 — Generation, Validation, Execution & Self-Repair + +Orchestrated by `backend/app/repair.py` → `run_repair_loop()`: + +1. **Generate**: OpenRouter AI is invoked (`backend/app/llm.py` → `generate_sql()`). The system prompt is constructed by `build_sql_system_prompt()`. The response returns structured JSON containing: + - `sql`: The generated query + - `restatement`: Plain English summary of what was computed + - `assumptions`: List of assumptions made by the model + - `chart`: Inferred visualization specification (chart type, x/y columns, title) +2. **Validate**: The SQL is parsed into an AST via `sqlglot` (`backend/app/sqlvalidate.py` → `validate_sql()`) to verify that all referenced tables and columns exist. +3. **Execute**: The query runs in **read-only** mode with statement timeouts and SSRF protection (`backend/app/database.py` → `DatabaseManager.execute()`). +4. **Self-Repair (if step 2 or 3 failed)**: The exact error is appended to the prompt (`_feedback()`), and the loop retries (up to 3 attempts within a 60-second window). + +--- + +## Step 4 — Confidence Scoring, Logging & Visualization + +- **Confidence**: Derived from real runtime signals (`schema_link.py` → `compute_confidence()`). +- **Persistence**: Recorded in PostgreSQL query history (`backend/app/pgdatabase/history.py`) and workspace activity log. +- **Rendering**: Returned as JSON (or streamed via Server-Sent Events). The frontend renders an `AnswerCard.svelte` (`frontend/src/lib/components/AnswerCard.svelte`) featuring: + - Plain English restatement + - Interactive data table + - **ECharts visualization** (`frontend/src/lib/components/ChartPanel.svelte`) based on the inferred `chart` spec + - Confidence pill and SQL view toggle + - **"Save to Dashboard"** button (`frontend/src/lib/components/SaveQueryDialog.svelte`) + +--- + +## Step 5 — Feedback & Verification Flywheel + +If a user clicks **"Yes, correct"** or **"Verify"**: +1. Frontend calls `POST /api/feedback` → `backend/app/routes/query.py` → `controllers/query.py`. +2. The question-and-SQL pair is added to PostgreSQL via `KnowledgeService.add_verified()`. +3. Subsequent similar queries retrieve this verified example during Step 1. diff --git a/docs/03-the-ai-layer-openrouter.md b/docs/03-the-ai-layer-openrouter.md index a9687ae3..1e5cdd8f 100644 --- a/docs/03-the-ai-layer-openrouter.md +++ b/docs/03-the-ai-layer-openrouter.md @@ -1,30 +1,79 @@ -# AI Layer — OpenRouter +# 3. The AI Layer — OpenRouter -BoloDB uses OpenRouter (`DeepSeek V4 Flash`) for all AI operations: -- SQL generation (question → SQL) -- SQL explanation (SQL → plain English) -- Glossary suggestion (onboarding) -- Starter question generation (onboarding) -- Semantic catalog suggestion -- Two-stage schema linking (table shortlisting) +BoloDB relies exclusively on **OpenRouter** (`deepseek/deepseek-v4-flash`) for all language-model operations. -## Configuration +--- -Set `OPENROUTER_API_KEY` in the environment. The key is shared across all -users — there is no per-user API key storage. +## 1. Responsibilities of the AI Layer -## Architecture +All AI interactions flow through a single module: `backend/app/llm.py`. The AI layer is responsible for: +1. **SQL Generation** (`generate_sql`): Translating plain English questions into valid database SQL queries, plain-English restatements, and chart visualization specs. +2. **SQL Explanation** (`explain_sql`): Translating raw SQL back into plain English for auditing. +3. **Semantic Catalog Suggestion** (`suggest_catalog`): Proposing domain-specific business terms, metrics, joins, and value maps based on database schema introspection. +4. **Starter Question Generation** (`generate_starters`): Generating sample questions and SQL pairs during onboarding to seed the knowledge base. +5. **Semantic Layer Enrichment**: Assisting in identifying joins, synonyms, and value mappings. +6. **Two-Stage Schema Linking**: Shortlisting tables on large schemas (30+ tables) before full schema linking. -All AI operations funnel through `backend/app/llm.py`: +--- -- `OpenRouterProvider` — wraps the OpenAI SDK pointed at - `https://openrouter.ai/api/v1`. Uses `deepseek/deepseek-v4-flash` with - `max_retries=2` and `temperature=0.1`. -- `create_provider()` — factory function, called at startup. -- `ProviderManager` — caches a single shared provider instance. +## 2. OpenRouter Architecture -## Structured Output +### Provider & Client (`backend/app/llm.py`) +- **`OpenRouterProvider`**: Wraps the `openai.AsyncOpenAI` SDK configured with base URL `https://openrouter.ai/api/v1`. +- **`ProviderManager`**: Thread-safe manager that maintains and caches the active provider instance. +- **Model**: `deepseek/deepseek-v4-flash` running with low temperature (`temperature=0.1`) for maximum determinism and code precision. +- **Configuration**: The API key is read strictly from the `OPENROUTER_API_KEY` environment variable. -All AI responses use OpenAI's `response_format: { type: "json_schema" }` -with `strict: true`. Schemas are defined as module-level constants in -`llm.py` with the `"name"` wrapper required by the API. +--- + +## 3. Structured Output Contract + +To ensure 100% reliable parsing without markdown wrapping or hallucinated structures, BoloDB uses OpenAI JSON Schema enforcement (`response_format={"type": "json_schema", ...}`). + +### `SQL_SCHEMA` Contract +The model returns a JSON object adhering to the following schema: + +```json +{ + "sql": "SELECT category_name, SUM(total_amount) AS revenue FROM orders JOIN products ON ... GROUP BY 1", + "restatement": "Computes total revenue for each product category from completed orders.", + "assumptions": ["Assumed completed orders means status = 'completed'."], + "chart": { + "type": "bar", + "x_axis": "category_name", + "y_axis": "revenue", + "title": "Revenue by Category", + "reason": "Bar chart compares revenue across product categories." + } +} +``` + +### Tolerant Parsing (`parse_json`) +If a model returns JSON wrapped in markdown code blocks (` ```json ... ``` `) or trailing whitespace, `llm.py`'s `parse_json()` utility strips code fences and extracts clean JSON. + +--- + +## 4. Streaming & Real-Time Responses + +For fast user feedback during query generation: +- The backend serves chunked Server-Sent Events (SSE) streaming responses on `POST /api/query/stream`. +- `frontend/src/lib/api.ts` provides `streamApiCall()`, which reads the SSE response stream chunk by chunk to display thinking state and partial output in real-time (`Thinking.svelte`). + +--- + +## 5. System Prompt Assembly + +The system prompt is assembled dynamically in `build_sql_system_prompt()` (`backend/app/llm.py`). It includes: +1. **Database Dialect**: e.g., PostgreSQL, MySQL, SQLite, DuckDB syntax rules. +2. **Linked Schema**: Compact table definitions produced by `compact_schema()` in `schema_link.py`. +3. **Glossary Terms**: Verified business definitions. +4. **Semantic Layer Context**: Metrics, join paths, synonyms, and value mappings from `pgdatabase/knowledge.py`. +5. **Verified Examples**: Similar past question/SQL pairs retrieved from PostgreSQL knowledge base. +6. **Conversation Context**: Last 2-3 chat turns for follow-up resolution. + +--- + +## 6. Error Handling & Retries + +- **Unified Exception**: All LLM operations raise a unified `LLMError(user_message, detail)` exception on failure. User-facing friendly messages (such as `HIGH_TRAFFIC_MESSAGE` for rate limits or provider downtime) are stored in `user_message`, while raw provider details or redacted errors are preserved in `detail`. +- **Retries**: Network failures or transient API errors trigger up to 2 retries with exponential backoff before raising `LLMError`. diff --git a/docs/05-safety-validation-and-self-repair.md b/docs/05-safety-validation-and-self-repair.md index 8861768b..cbfec20a 100644 --- a/docs/05-safety-validation-and-self-repair.md +++ b/docs/05-safety-validation-and-self-repair.md @@ -1,127 +1,84 @@ -# 5. Safety, validation and self-repair +# 5. Safety, validation, and self-repair -AI-generated SQL fails in two ways: it can be **dangerous** (tries to change -data) or **broken** (references things that don't exist, wrong syntax). -BoloDB handles both with three independent layers. Each layer lives in its -own file and is separately tested. +AI-generated SQL fails in two ways: it can be **dangerous** (tries to change data) or **broken** (references non-existent columns, syntax errors). Furthermore, database connections must be protected against network exploits. + +BoloDB handles safety and reliability through multiple defense-in-depth layers. ```text + Database Connection Attempt + │ + ▼ + [0] SSRF & Network Security ── host allowlist, loopback blocking database.py + │ AI writes SQL │ ▼ - ① Static validation — does every table/column actually exist? sqlvalidate.py + ① Static validation ──────── does every table/column exist? sqlvalidate.py │ pass ▼ - ② Read-only guard — is this a pure SELECT? nothing else runs database.py + ② Read-only guard ──────── is this a pure SELECT statement? database.py │ pass ▼ - ③ Execution — did the database accept it? database.py + ③ Exec & Statement Timeout ── 5s timeout & execution safety database.py │ any failure at ①/③ ▼ - ↻ Self-repair loop — feed the exact error back to the AI repair.py + ↻ Self-repair loop ──────── feed exact error back to AI repair.py ``` +--- + +## Layer 0 — Network Security & SSRF Protection (`backend/app/database.py`) + +**Function:** `_validate_db_url(db_url)` + +Before a database connection is established, the target URL undergoes strict Server-Side Request Forgery (SSRF) validation: +- **Loopback & Private Address Blocking**: Requests targeting `localhost`, `127.0.0.1`, `0.0.0.0`, `::1`, or link-local / cloud metadata IPs (`169.254.169.254`) are strictly blocked. +- **Host Allowlisting**: Connection hosts are validated to prevent internal infrastructure scanning. +- **Encrypted Credentials at Rest**: Connection details stored in PostgreSQL are encrypted using Fernet symmetric keys (`backend/app/pgdatabase/connections.py`). + +--- + ## Layer ① — Static validation (`backend/app/sqlvalidate.py`) **Function:** `validate_sql(sql, schema, dialect)` -Before generated SQL goes anywhere near your database, it is parsed into a -syntax tree (using the `sqlglot` library) and every table and column it -references is checked against your *actual* schema. This catches the most -common AI mistake — confidently inventing a column: +Before generated SQL touches your database, it is parsed into a syntax tree (using `sqlglot`) and every table and column referenced is checked against your *actual* schema. This catches column hallucinations instantly: > `Unknown column: 'revenue' does not exist in table 'orders'.` -That sentence is not just an error — it's written to be **fed back to the AI** -so it can fix itself (see layer ↻ below). - -Design principle stated at the top of the file: the validator is deliberately -*conservative*. It only complains when it is certain something is wrong -(unknown table, unknown column on a known table). Columns that might come -from a subquery, CTE, or **SELECT alias** are left alone — a false rejection -of valid SQL would be worse than missing an error, because execution -(layer ③) is the final backstop anyway. Column aliases from the SELECT list -are collected and treated as valid references in ORDER BY / GROUP BY / HAVING. - -Tests: `tests/test_sqlvalidate.py`. - -## Layer ② — The read-only guard (`backend/app/database.py`) - -**Function:** `DatabaseManager._readonly_violation()`, called from -`execute()` on **every** query — including raw SQL a user runs directly via -`/api/execute`. - -The rules, enforced on the parsed syntax tree (not just text matching): - -- Only a single statement (no stacked `SELECT …; DROP …`). -- The statement root must be SELECT / UNION / EXPLAIN. -- **No modifying node anywhere in the tree** — this catches sneaky cases - like a DELETE hidden inside a CTE (`WITH x AS (DELETE …)`). The node - blocklist is `_MODIFYING_NODES`. -- `SELECT … INTO` (which creates a table) is blocked explicitly. -- If the SQL can't even be parsed, a conservative keyword blocklist - (`WRITE_KEYWORDS`) rejects anything suspicious instead of running it - blindly. - -On top of this, results are capped at 500 rows (`max_rows`) and the README -recommends connecting with a read-only database account — defense in depth. - -Tests: `tests/test_database.py`. - -## Layer ③ — Execution errors become repair fuel - -If the database itself rejects the query (syntax quirk, type error, missing -extension), `execute()` returns `{"error": ""}` -rather than raising — so the error text can be recycled by the repair loop. - -## Layer ↻ — The self-repair loop (`backend/app/repair.py`) - -**Function:** `run_repair_loop(generate, validate, execute, …)` — wired into -the live pipeline in `backend/app/controllers/query.py` → `run_query()`. - -The insight: most wrong SQL is wrong *mechanically*, and the AI can fix it -instantly **if you tell it exactly what broke**. So instead of giving up on -the first failure: - -1. Generate a candidate. -2. If validation (①) or execution (③) fails, build a correction prompt — - `_feedback()` produces: - - ```text - The previous SQL attempt was: - SELECT revenue FROM orders - Problems found: - - Unknown column: 'revenue' does not exist in table 'orders'. - Return a corrected SQL query that fixes these problems. - ``` - -3. Ask again with that feedback appended (it flows into - `generate_sql(..., feedback=...)` in `llm.py`). -4. **Schema-retry:** before regenerating, the failed SQL is checked for - tables that exist in the database but weren't shown to the model — a sign - the *table selection* (not the SQL) was the problem. Any such table is - added to the prompt for the next attempt. Details in - [chapter 4](04-schema-linking.md); code: - `expand_linked_tables()` + the `on_failure` hook. -5. Repeat — **at most 3 attempts total, and no new attempt starts after 60 - seconds** (constants `_MAX_ATTEMPTS` / `_MAX_SECONDS` in - `controllers/query.py`), so a stubborn failure can't burn tokens or leave - the user staring at a spinner forever. - -What you see as a user: usually nothing — the answer just works. The response -does carry the evidence: `attempts` (how many generations were needed) and -`repaired: true` if a fix happened mid-flight. If all attempts fail, you get -a Low-confidence answer with the final error in `execution_error` and a -suggestion to rephrase. - -Tests: `tests/test_repair.py` (the loop in isolation), -`tests/test_query_pipeline.py` (the loop wired into the real pipeline — -including proof that invalid SQL **never reaches the database**). - -## What still can't go wrong silently - -- A query that runs but answers the *wrong question* is the one failure no - validator can catch. That's what the plain-English **restatement**, the - **assumptions list**, the **Explain** button and the **confidence badge** - are for — they make it easy for a human to spot. See - [chapter 6](06-learning-trust-and-confidence.md). +The validator is deliberately conservative: it only complains when certain something is wrong. Columns coming from CTEs, subqueries, or aliases are preserved. + +--- + +## Layer ② — Read-only Guard (`backend/app/database.py`) + +**Function:** `DatabaseManager._readonly_violation()`, called from `execute()` on **every** query. + +Enforced on the parsed syntax tree: +- Single statement restriction (blocks stacked queries like `SELECT ...; DROP ...`). +- Statement root must be `SELECT`, `UNION`, or `EXPLAIN`. +- **No modifying node anywhere in the tree** — catches mutations inside CTEs (`WITH x AS (DELETE ...)`). +- Blocked node types: `INSERT`, `UPDATE`, `DELETE`, `DROP`, `ALTER`, `CREATE`, `TRUNCATE`, `SELECT INTO`. +- Keyword fallback blocklist if AST parsing fails. + +--- + +## Layer ③ — Execution Safety & Statement Timeout + +**Function:** `_apply_statement_timeout(engine, dialect)` in `backend/app/database.py`. + +- **Statement Timeouts**: Every connection applies statement timeouts (e.g., `SET statement_timeout = 5000` on PostgreSQL / MySQL `max_execution_time`) to prevent runaway long-running queries from overwhelming the target database. +- **Row Caps**: Execution results are capped at 500 rows (`max_rows`) to prevent memory exhaustion. +- **Error Recycling**: Execution errors are returned cleanly to supply fuel for self-repair. + +--- + +## Layer ↻ — Self-Repair Loop (`backend/app/repair.py`) + +**Function:** `run_repair_loop()` + +When static validation (Layer ①) or database execution (Layer ③) fails: +1. BoloDB constructs a targeted correction prompt (`_feedback()`). +2. The exact error message is fed back to OpenRouter in `generate_sql(..., feedback=...)`. +3. **Schema-retry**: If the failed SQL references a real table that schema linking missed, that table is added to the context prompt for the next attempt. +4. **Limits**: Maximum 3 attempts, with a hard 60-second total execution deadline. diff --git a/docs/06-learning-trust-and-confidence.md b/docs/06-learning-trust-and-confidence.md index 2d63be3a..297fc0cf 100644 --- a/docs/06-learning-trust-and-confidence.md +++ b/docs/06-learning-trust-and-confidence.md @@ -1,42 +1,30 @@ -# 6. Learning, trust and confidence +# 6. Learning, trust, and confidence -BoloDB's accuracy is not static — it improves with use, per database, because -of a simple loop: **every answer you confirm becomes an example the AI sees -next time.** This chapter covers the knowledge base, the confidence badge, -and the trust level. +BoloDB's accuracy is not static — it improves with use, per workspace and database connection, because of a simple loop: **every answer you confirm becomes an example the AI sees next time.** -## The knowledge base (`backend/app/knowledge.py`) +--- -A small local SQLite file (`~/.bolodb/knowledge.db`, path set in -`backend/app/config.py`) with two tables, both keyed by a database -fingerprint (`db_id`, derived in `backend/app/database.py` → `db_id_for()` — -so knowledge for one connected database never leaks into another): +## 1. The Knowledge Base (`backend/app/pgdatabase/knowledge.py`) -1. **`verified`** — question, SQL, restatement of every answer you confirmed. - Written by `KnowledgeBase.add_verified()` (near-duplicate questions are - skipped — similarity > 0.92). -2. **`glossary`** — your confirmed business terms: - term → plain meaning → SQL hint. Written during onboarding - (`set_glossary()`). +Knowledge is persisted asynchronously in PostgreSQL via `KnowledgeService`. All entries are strictly scoped by **Workspace ID** and **Database ID** (`db_id`), ensuring data from one workspace or database never leaks to another. -### How verified answers are found again +### Managed Entities +1. **Verified Q&A (`VerifiedQA`)**: Question, SQL, restatement, and metadata of every answer confirmed by a workspace member (`add_verified()`). Near-duplicate questions are skipped if similarity > 0.92. +2. **Business Glossary (`Glossary`)**: Workspace-confirmed business terms (`term` → `meaning` → `sql_hint`), managed via `set_glossary()`. +3. **Semantic Layer**: Business metrics, join rules, synonyms, and value mappings (`CatalogMetric`, `CatalogJoin`, `CatalogSynonym`, `CatalogValueMapping`), managed via `set_catalog()`. -When a new question arrives, `retrieve_similar()` scores it against every -stored verified question: +### Retrieval & Similar Question Matching +When a new question arrives, `retrieve_similar()` retrieves up to 3 similar verified Q&A entries using Jaccard word-overlap and string distance algorithms (`_similarity()`): -```text -similarity = 0.6 × word-overlap (Jaccard) + 0.4 × character-sequence ratio -``` +$$\text{similarity} = 0.6 \times \text{word-overlap} + 0.4 \times \text{sequence-similarity}$$ -(code: `_similarity()`). The top 3 above a 0.25 threshold are injected into -the AI prompt as worked examples (`_examples_block()` in -`backend/app/llm.py`) — few-shot examples of *your own verified SQL* are the -strongest accuracy signal the system has. +Matches above a 0.25 threshold are injected directly into the system prompt as worked examples (`_examples_block()` in `backend/app/llm.py`). -## The confidence badge (`backend/app/schema_link.py` → `compute_confidence()`) +--- -The High/Medium/Low badge is computed from **observable signals** — never -from asking the AI how confident it feels. The exact rules: +## 2. The Confidence Badge (`backend/app/schema_link.py` → `compute_confidence()`) + +The High/Medium/Low badge is derived strictly from **observable runtime signals**: | Situation | Badge | Reason shown | |---|---|---| @@ -46,53 +34,36 @@ from asking the AI how confident it feels. The exact rules: | No match, query returned zero rows | **Low** | "no matching rows - the question may not match your data" | | No match, query returned rows | **Medium** | "new question - please confirm it is right" | -Note what this implies: **the only way to see High confidence is to verify -answers.** That's intentional — high confidence is earned from your -confirmations, not claimed. +--- -## The trust level (Supervised → Assisted → Trusted) +## 3. The Trust Level (Supervised → Assisted → Trusted) -A per-database summary of how much verified knowledge exists — computed in -`KnowledgeBase.trust_level()` and mirrored in the frontend -(`frontend/src/lib/data.ts` → `trustFor()`): +A workspace database trust summary computed in `KnowledgeService` and reflected in the UI (`frontend/src/lib/data.ts` → `trustFor()`): | Verified answers | Level | Product behaviour | |---|---|---| -| 0–2 | **Supervised** | Every answer waits for your confirmation while it learns. | -| 3–6 | **Assisted** | Confident answers shown; novel ones get a second look. | -| 7+ | **Trusted** | Answers shown directly; reasoning on tap. | - -Displayed as the pill in the chat top bar -(`frontend/src/lib/components/ui/TrustPill.svelte`) and the meter in -onboarding (`TrustMeter.svelte`). - -## The feedback flow, end to end - -1. You click **"Yes, correct"** on an answer card - (`frontend/src/lib/components/AnswerCard.svelte`). -2. `POST /api/feedback` → `backend/app/controllers/query.py` → - `feedback()`. -3. The verdict is logged (`backend/app/logbook.py` — an append-only JSONL - file per session in `~/.bolodb/sessions/`, useful for auditing what was - asked and answered). -4. If the verdict is "correct", the pair is stored via `add_verified()` and - the new trust level is returned to the UI. -5. Clicking **"Something's wrong"** logs the verdict + reason (wrong numbers / - wrong filter / not what I meant …) without storing the answer. - -## Explain — trust without reading SQL - -For any query, `POST /api/explain` (`backend/app/llm.py` → `explain_sql()`) -returns 2–4 plain-English sentences describing what the SQL actually does — -which data it looks at, how it filters and groups, how it's ordered. It's the -"reverse translation" that lets a non-technical user audit an answer without -learning SQL. - -## Where each artifact lives on disk - -| Artifact | Location | Written by | +| 0–2 | **Supervised** | Every answer prompts for confirmation while learning. | +| 3–6 | **Assisted** | Confident answers displayed; novel ones get a second look. | +| 7+ | **Trusted** | Answers displayed directly; reasoning on tap. | + +--- + +## 4. End-to-End Feedback & Verification Flow + +1. User clicks **"Yes, correct"** or **"Verify"** on an answer card (`frontend/src/lib/components/AnswerCard.svelte`). +2. Frontend issues `POST /api/feedback` or `POST /api/verify` → `backend/app/routes/query.py` → `backend/app/controllers/query.py`. +3. The verified pair is saved into PostgreSQL via `KnowledgeService.add_verified()`. +4. Workspace activity is recorded in `WorkspaceActivityLog`. + +--- + +## 5. Persistence Map in BoloDB v2 + +| Entity | Storage | Managed by | |---|---|---| -| Settings (model, API key — key encrypted at rest) | `~/.bolodb/config.json` + `~/.bolodb/.secret` | `backend/app/config.py` | -| Verified answers + glossary | `~/.bolodb/knowledge.db` | `backend/app/knowledge.py` | -| Session query/feedback log | `~/.bolodb/sessions/session-*.jsonl` | `backend/app/logbook.py` | -| Per-user query history | MongoDB (Docker volume) | `backend/app/mongodatabase.py` | +| Users & Workspaces | PostgreSQL | `backend/app/pgdatabase/users.py`, `workspaces.py` | +| Verified Q&A, Glossary, Catalog | PostgreSQL | `backend/app/pgdatabase/knowledge.py` | +| Encrypted DB Connections | PostgreSQL | `backend/app/pgdatabase/connections.py` | +| Query History | PostgreSQL | `backend/app/pgdatabase/history.py` | +| Dashboards & Saved Queries | PostgreSQL | `backend/app/pgdatabase/dashboards.py`, `saved_queries.py` | +| Audit Trail / Session Log | PostgreSQL | `backend/app/models/activity.py` | diff --git a/docs/07-file-map.md b/docs/07-file-map.md index d4e57f9e..1c547d5b 100644 --- a/docs/07-file-map.md +++ b/docs/07-file-map.md @@ -8,89 +8,217 @@ Use this as an index: find the thing you care about, open the file next to it. | File | What it does | |---|---| -| `backend/main.py` | Command-line entry point. Parses `--db/--port/...`, warns if no OpenRouter key is configured, starts the web server. | -| `backend/app/server.py` | Builds the FastAPI app: creates the shared objects (config, provider manager, database manager, knowledge base, session log) and mounts all routes. | -| `backend/sample_data.py` | Builds the "Try with sample data" demo database (TechStore e-commerce). | +| `backend/app/server.py` | Main FastAPI server entry point — builds the app (`create_app()`), initializes shared objects (config, ProviderManager, DatabaseManager, KnowledgeService, SessionLog), runs Alembic migrations at startup, mounts all route modules, applies CORS + rate limiting + body-limit middleware. | +| `backend/sample_data.py` | Builds the "Try with sample data" demo database (PostgreSQL webshop dataset). | ### The AI + question pipeline (the heart of the product) | File | What it does | |---|---| -| `backend/app/llm.py` | **The only file that talks to an AI.** `OpenRouterProvider` (HTTP calls, retries, error taxonomy), the prompt builders, and the four AI operations: `generate_sql`, `explain_sql`, `generate_glossary`, `generate_starters`. → [chapter 3](03-the-ai-layer-openrouter.md) | -| `backend/app/schema_link.py` | Scores tables against the question and picks which ones the AI sees (`link_relevant_tables`), renders them compactly (`compact_schema`), sets per-model budgets (`model_budget`), computes the confidence badge (`compute_confidence`). → [chapter 4](04-schema-linking.md) | +| `backend/app/llm.py` | **The only file that talks to an AI model.** `OpenRouterProvider` (HTTP calls via openai SDK, retries, error taxonomy, streaming), the structured-output schemas (`SQL_SCHEMA`, `CHART_TYPES`), `suggest_catalog()`, and the AI operations: `generate_sql`, `explain_sql`, `suggest_catalog`, `generate_starters`. Now produces a `chart` spec alongside SQL. → [chapter 3](03-the-ai-layer-openrouter.md) | +| `backend/app/schema_link.py` | Scores tables against the question and picks which ones the AI sees (`link_relevant_tables`), renders them compactly (`compact_schema`), sets per-model budgets (`model_budget`), expands FK/junction tables (`expand_linked_tables`), computes the confidence badge (`compute_confidence`). → [chapter 4](04-schema-linking.md) | | `backend/app/sqlvalidate.py` | Static validation: parses generated SQL and checks every table/column against the real schema before execution. → [chapter 5](05-safety-validation-and-self-repair.md) | | `backend/app/repair.py` | The generate→validate→execute→repair loop that auto-fixes broken SQL by feeding the exact error back to the AI. → [chapter 5](05-safety-validation-and-self-repair.md) | -| `backend/app/knowledge.py` | The local knowledge base (SQLite): verified Q→SQL answers, glossary, similarity retrieval, trust level. → [chapter 6](06-learning-trust-and-confidence.md) | -| `backend/app/database.py` | Connects to *your* database (SQLAlchemy), introspects the schema (tables, columns, keys, sample values, row counts), and executes queries with the read-only guard. | -| `backend/app/config.py` | Settings on disk (`~/.bolodb/config.json`): OpenRouter model + API key, allowed model list, migration from pre-OpenRouter configs, `OPENROUTER_API_KEY` env fallback. | +| `backend/app/controllers/query.py` | **`run_query()`** — orchestrates the whole pipeline: knowledge lookup → schema linking → generate/validate/execute/repair → confidence scoring → chart inference → logging. → [chapter 2](02-how-a-question-becomes-an-answer.md) | +| `backend/app/semantic.py` | Semantic catalog helpers: `suggest_from_schema()` derives joins and value maps from the schema, `merge_catalog_suggestions()` combines them with LLM enrichment, `filter_catalog()` scopes entries to linked tables. → [chapter 12](12-semantic-layer.md) | +| `backend/app/database.py` | `DatabaseManager` — connects to *your* database (SQLAlchemy), introspects schema (tables, columns, PKs, FKs, sample rows, row counts, distinct values), read-only guard via AST parsing, SSRF validation, statement timeout. Keyed per workspace+db_id. | +| `backend/app/config.py` | In-memory only. API key read from `OPENROUTER_API_KEY` env var. Activity log retention settings also live here. | | `backend/app/utils.py` | `_tokens()` — the cached word tokenizer used by schema linking and similarity scoring. | -### HTTP layer +### Routes and controllers -Routes (`backend/app/routes/`) are thin — they parse the request and call a -controller (`backend/app/controllers/`) where the logic lives. +Routes (`backend/app/routes/`) are thin — they parse the request, enforce permissions, and call a controller (`backend/app/controllers/`) where the logic lives. | Route file | Endpoints | Controller | |---|---|---| -| `routes/query.py` | `POST /api/query` (ask a question), `/api/feedback`, `/api/verify`, `/api/execute` (raw SQL), `/api/explain` (SQL → English) | `controllers/query.py` — **`run_query()` is the whole pipeline** | -| `routes/system.py` | `GET /api/state`, `GET /api/health`, `POST /api/config` (model + API key) | `controllers/system.py` | -| `routes/database.py` | `/api/connect`, `/api/connect-sample`, `/api/reconnect`, `/api/disconnect`, `/api/schema` | `controllers/database.py` | -| `routes/onboard.py` | `/api/onboard/glossary`, `/api/onboard/starters`, `/api/onboard/save` | `controllers/onboard.py` | -| `routes/auth.py` | signup/login/logout/refresh/me/change-password (JWT) | `controllers/auth.py` | -| `routes/history.py` | `GET/DELETE /api/history` (per-user query history) | `controllers/history.py` | -| `routes/connections.py` | `GET/DELETE /api/connections` (recent databases) | `controllers/connections.py` | - -### Support +| `routes/query.py` | `POST /api/query`, `POST /api/query/stream`, `/api/feedback`, `/api/verify`, `/api/execute`, `/api/explain` | `controllers/query.py` — `run_query()` is the whole pipeline | +| `routes/system.py` | `GET /api/health`, `GET /api/state`, `GET /api/config`, `POST /api/config`, `POST /api/tour-complete` | `controllers/system.py` | +| `routes/database.py` | `GET /api/databases`, `POST /api/connect`, `POST /api/connect-sample`, `POST /api/disconnect`, `GET /api/schema` | `controllers/database.py` | +| `routes/onboard.py` | `POST /api/onboard/save`, `POST /api/onboard/generate-starters` | `controllers/onboard.py` | +| `routes/auth.py` | `POST /api/auth/signup`, `/login`, `/logout`, `/refresh`, `/api/auth/me`, `/api/auth/change-password`, `/api/auth/supabase-google`, email verification, forgot password | `controllers/auth.py` | +| `routes/history.py` | `GET/DELETE /api/history` (per-user query history) | `controllers/query.py` | +| `routes/connections.py` | `GET/DELETE /api/connections` (recent databases, encrypted at rest) | `controllers/database.py` | +| `routes/catalog.py` | `GET /api/catalog`, `POST /api/catalog`, `POST /api/catalog/suggest` | `controllers/catalog.py` | +| `routes/conversations.py` | `GET/POST/PATCH/DELETE /api/conversations` | `controllers/conversations.py` | +| `routes/workspaces.py` | `GET/POST/PATCH/DELETE /api/workspaces`, invites, members | `controllers/workspaces.py` | +| `routes/dashboards.py` | `GET/POST/PATCH/DELETE /api/dashboards`, panel batch update | `controllers/dashboards.py` | +| `routes/saved_queries.py` | `GET/POST/DELETE /api/saved-queries` | `controllers/dashboards.py` | +| — | Activity logs controller | `controllers/activity.py` | + +### Auth, permissions, and dependencies | File | What it does | |---|---| -| `backend/app/dependencies.py` | FastAPI dependency injection: current user from JWT, shared app-state objects. | -| `backend/app/models/api.py` | Request body shapes (pydantic): `QueryReq`, `ConfigUpdate`, `ConnectReq`, ... | -| `backend/app/models/user.py` | User model for auth. | -| `backend/app/pgdatabase/` | PostgreSQL persistence: user accounts, query history, recent connections, conversations. | -| `backend/app/logbook.py` | Append-only session log (`~/.bolodb/sessions/*.jsonl`) of every query + feedback. | +| `backend/app/dependencies.py` | FastAPI dependency injection: `get_current_user` (JWT cookie), `get_current_workspace` (X-Workspace-Id header + membership check), `get_current_db_id` (X-Db-Id header), `require_role()`, `require_permission()`, `get_db()`, `get_kb()`, `get_providers()`, `get_cfg()`. | +| `backend/app/secrets.py` | Centralized secret management: `get_jwt_secret()`, `get_supabase_jwt_secret()`, `get_resend_api_key()`, `get_frontend_url()`, `get_cookie_secure()`. | +| `backend/app/permissions.py` | RBAC permission registry — 21 fine-grained capabilities across 7 resources (members, connections, catalog, dashboards, queries, activity, workspace_management). `resolve_role_permissions()`, `has_permission()`. | +| `backend/app/ratelimit.py` | Shared slowapi `Limiter` instance with X-Forwarded-For-aware client key. | -## Frontend (`frontend/src/`) — SvelteKit +### PostgreSQL persistence (`backend/app/pgdatabase/`) | File | What it does | |---|---| -| `routes/+page.svelte` | Marketing/landing page. | -| `routes/connect/+page.svelte` → `lib/components/ConnectScreen.svelte` | Step 1: OpenRouter API key setup. Step 2: connect a database (form/URL/sample) + recent databases. | -| `routes/onboard/+page.svelte` → `OnboardScreen.svelte`, `GlossaryStep.svelte`, `StartersStep.svelte`, `ProfileStep.svelte` | First-time onboarding: confirm business terms, verify starter questions. | -| `routes/chat/+page.svelte` → `AskScreen.svelte` | The main chat screen: input, answer feed, sidebar. | -| `lib/components/AnswerCard.svelte` | One answer: restatement, results table, SQL toggle, confidence badge, verify buttons. | -| `lib/components/Sidebar.svelte` | Schema browser, history, trust meter, engine indicator (OpenRouter), Settings button. | +| `pgdatabase/engine.py` | Async SQLAlchemy engine and session factory. `get_engine()`, `DATABASE_URL` env var → `create_async_engine()`. `async_session`, `dispose_db()`. | +| `pgdatabase/knowledge.py` | `KnowledgeService` — verified Q&A storage, glossary, semantic catalog (column descriptions, metrics, joins, synonyms, value maps). All async, all PostgreSQL. `add_verified()`, `retrieve_similar()`, `get_glossary()`, `set_glossary()`, `get_catalog()`, `set_catalog()`. | +| `pgdatabase/users.py` | User CRUD: `create_user()`, `get_user_by_email()`, `update_user()`. | +| `pgdatabase/connections.py` | Recent connections with Fernet encryption at rest. `RECENT_CONNECTIONS_KEY` env var. `save_recent_connection()`, `get_recent_connections()`, `get_recent_connection_by_db_id()`. | +| `pgdatabase/conversations.py` | Conversation CRUD, scoped per workspace+user. | +| `pgdatabase/history.py` | Query history CRUD, scoped per workspace+user+db. | +| `pgdatabase/dashboards.py` | Dashboard and panel CRUD. | +| `pgdatabase/saved_queries.py` | Saved query CRUD. | +| `pgdatabase/otp.py` | One-time password/pin for invite acceptance. | +| `pgdatabase/serialization.py` | UUID helpers and serialization utilities. | + +### Support & Models (`backend/app/models/`) + +| File | What it does | +|---|---| +| `backend/app/models/api.py` | Pydantic request/response models: `QueryReq`, `ConfigUpdate`, `ConnectReq`, `FeedbackReq`, `VerifyReq`, etc. | +| `backend/app/models/workspace_api.py` | Pydantic request/response models for workspace API operations (workspace creation, updating, membership, permission matrix updates). | +| `backend/app/models/user.py` | User Pydantic model. | +| `backend/app/models/orm_user.py` | SQLAlchemy ORM User model. | +| `backend/app/models/workspace.py` | Workspace + WorkspaceMember ORM models. | +| `backend/app/models/workspace_settings.py` | WorkspaceSettings ORM model — role permissions overrides. | +| `backend/app/models/catalog.py` | VerifiedQA, Glossary, CatalogColumn, CatalogMetric, CatalogJoin, CatalogSynonym, CatalogValueMapping ORM models. | +| `backend/app/models/conversation.py` | Conversation ORM model. | +| `backend/app/models/activity.py` | WorkspaceActivityLog ORM model. | +| `backend/app/models/dashboard.py` | Dashboard + DashboardPanel ORM models. | +| `backend/app/models/saved_query.py` | SavedQuery ORM model. | +| `backend/app/models/recent_connection.py` | RecentConnection ORM model. | +| `backend/app/models/auth_token.py` | AuthToken ORM model. | +| `backend/app/models/base.py` | Base ORM model class with `_uuid7()` helper. | +| `backend/app/logbook.py` | `SessionLog` — thin class that mints per-query IDs. Durable records live in PostgreSQL (`query_history`). | +| `backend/app/services/email.py` | Email sending via Resend API. | +| `backend/app/services/email_verification.py` | Email verification + forgot-password token flows. | +| `backend/requirements.txt` | Python dependencies. | +| `backend/alembic/` | Alembic migration files for schema versioning. Automatically runs `upgrade head` on server start. | +| `backend/alembic.ini` | Alembic configuration. | +| `backend/DOCKERFILE` | Backend Docker image. | +| `backend/docker-entrypoint.sh` | Container entrypoint. | + +## Frontend (`frontend/src/`) — SvelteKit 5 + +### Routes + +| Route | Component(s) | What it does | +|---|---|---| +| `frontend/src/routes/(marketing)/+page.svelte` | — | Marketing/landing page (9 sections, GSAP+Lenis animations). Prerendered, heavily SEO-optimised (JSON-LD, OG tags, sitemap). | +| `routes/auth/callback/+page.svelte` | — | Supabase / OAuth callback page. | +| `routes/signup/+page.svelte` | `SignupScreen.svelte` | Sign up with email+password or Google OAuth. | +| `routes/login/+page.svelte` | `LoginScreen.svelte` | Login form. | +| `routes/connect/+page.svelte` | `ConnectScreen.svelte` | Connect a database (form/URL/sample) within a workspace context. | +| `routes/chat/+page.svelte` | `AskScreen.svelte`, `AppShell.svelte` | Main chat screen: input (natural language + SQL), answer feed, sidebar. | +| `routes/dashboards/+page.svelte` | — | Dashboard list view. | +| `routes/dashboards/[id]/+page.svelte` | `DashboardEditor.svelte` | Single dashboard with panels and charts. | +| `routes/dashboards/[id]/edit/+page.svelte` | `DashboardEditor.svelte` | Dashboard edit mode. | +| `routes/workspaces/+page.svelte` | — | Workspace list. | +| `routes/workspaces/setup/+page.svelte` | — | Workspace setup wizard. | +| `routes/profile/+page.svelte` | `ProfileStep.svelte` | Account profile & settings. | +| `routes/verify-email/+page.svelte` | — | Email verification page. | +| `routes/forgot-password/+page.svelte` | — | Forgot password form. | +| `routes/reset-password/+page.svelte` | — | Password reset form. | +| `routes/privacy/+page.svelte` | — | Privacy policy page. | +| `routes/terms/+page.svelte` | — | Terms of service page. | + +### Key components (`frontend/src/lib/components/`) + +| Component | What it does | +|---|---| +| `lib/components/AppShell.svelte` | Main layout wrapper — sidebar + content area. | +| `lib/components/AskScreen.svelte` | Main chat: message input, answer feed, streaming responses. | +| `lib/components/AnswerCard.svelte` | One answer: restatement, results table, chart, SQL toggle, confidence badge, verify buttons, "Save to Dashboard". | +| `lib/components/AuthChoiceModal.svelte` | Modal component for choosing authentication method (email vs Google sign-in). | +| `lib/components/ChartPanel.svelte` | ECharts visualization panel component. | +| `lib/components/DataCatalog.svelte` | Data catalog management component for glossary, metrics, and value mappings. | +| `lib/components/ExitIntentModal.svelte` | Modal overlay for capturing landing page exit intent. | +| `lib/components/Flywheel.svelte` | Interactive animated flywheel component for workflow visual presentation. | +| `lib/components/GoogleSignIn.svelte` | Google OAuth sign-in button component. | +| `lib/components/ProfileStep.svelte` | Profile setup and account details step component. | +| `lib/components/SettingsTab.svelte` | Tabbed settings sub-component for workspace configuration. | +| `lib/components/Sidebar.svelte` | Schema browser, conversation list, database switcher, trust meter, settings button. | | `lib/components/Settings.svelte` | OpenRouter model picker + API key management. | -| `lib/appState.svelte.ts` | Global client state: engine/model, connection info, trust count, navigation flow. | -| `lib/api.ts` | `apiCall()` — fetch wrapper with auth handling. | -| `lib/data.ts` | Static data: the OpenRouter provider entry, trust-level definitions, demo schema, human-friendly error translations (`humanError`). | +| `lib/components/ConnectScreen.svelte` | Database connection form (workspace-aware, split layout). | +| `lib/components/DashboardEditor.svelte` | Dashboard grid with draggable/resizable panels. | +| `lib/components/SaveQueryDialog.svelte` | Modal to save a query to a dashboard. | +| `lib/components/ProductTour.svelte` | Interactive onboarding tour overlay. | +| `lib/components/ActivityLog.svelte` | Workspace activity log viewer. | +| `lib/components/Stepper.svelte` | Step progress component for setup and onboarding flows. | +| `lib/components/Thinking.svelte` | Indicator displaying real-time query generation and reasoning progress. | +| `lib/components/TrustToast.svelte` | Toast notification component for displaying database trust status updates. | +| `lib/components/charts/ChartCard.svelte` | Card container component wrapping chart visualizations. | +| `lib/components/charts/ResultChart.svelte` | High-level chart component for rendering query results using ECharts. | +| `lib/components/charts/chartUtils.ts` | Utilities for ECharts configuration, data formatting, and color palettes. | + +### State, API, and utilities + +| File | What it does | +|---|---| +| `lib/appState.svelte.ts` | Global client state (Svelte 5 runes): user, active workspace, engine/model, connection info, trust count, navigation flow. | +| `lib/api.ts` | `apiCall()` and `streamApiCall()` — fetch wrappers with auth headers (workspace + db). Typed endpoint functions. | +| `lib/data.ts` | Static data: provider entries, trust-level definitions, human-friendly error translations (`humanError`). | | `lib/types.ts` | TypeScript interfaces shared across components. | +| `lib/i18n/` | Localized strings (`typesafe-i18n` with server-detect locale + cookie persistence). | +| `hooks.server.ts` | Server hooks: posthog analytics, locale resolution. | +| `hooks.client.ts` | Client hooks: app initialization, auth redirect. | +| `app.html` | HTML shell. | +| `app.d.ts` | TypeScript declarations. | ## Tests (`tests/`) +The repository includes a 37-file backend unit and integration test suite: + | File | Covers | |---|---| -| `tests/test_openrouter.py` | OpenRouterProvider: request shape, structured output, retries, error taxonomy, health check (fake HTTP — no network). | -| `tests/test_query_pipeline.py` | `run_query()` end-to-end with fakes: repair-on-validation-failure, repair-on-execution-error, failure paths. Proves bad SQL never reaches the database. | -| `tests/test_schema_link.py` | Table scoring, plural matching, glossary expansion, FK/junction expansion, budgets, confidence rules. | -| `tests/test_repair.py` | The repair loop in isolation: feedback content, iteration/time bounds. | -| `tests/test_sqlvalidate.py` | Static SQL validation against a schema. | -| `tests/test_structured_output.py` | The JSON output contract (`SQL_SCHEMA`) and tolerant parsing. | -| `tests/test_llm.py` | `parse_json` fence/noise stripping. | -| `tests/test_config.py` | Config defaults, old-config migration, env-var key fallback, secret never exposed. | -| `tests/test_database.py`, `test_database_controller.py` | Read-only guard, connection handling. | -| `tests/test_knowledge.py` | Knowledge base storage + similarity retrieval. | -| `tests/test_history.py` | History endpoints. | - -Run them all: `pytest tests` from the repository root (install -`backend/requirements.txt` first). +| `tests/test_activity_cleanup.py` | Retention and automated cleanup of workspace activity logs. | +| `tests/test_catalog_controller.py` | Data catalog endpoints (read, save, AI suggest). | +| `tests/test_config.py` | Config defaults, env-var key fallback, secret protection. | +| `tests/test_connection_encryption.py` | Encryption and decryption of database connection secrets at rest. | +| `tests/test_connection_rehydration.py` | Rehydration of saved connection parameters into live database engines. | +| `tests/test_database.py` | `DatabaseManager`: connection pooling, schema introspection, SSRF validation. | +| `tests/test_database_controller.py` | Database controller routes: connect, disconnect, sample database. | +| `tests/test_dependencies.py` | FastAPI dependency injection, auth validation, and header scoping. | +| `tests/test_email_verification.py` | Email verification token generation, verification endpoints, and password resets. | +| `tests/test_history.py` | Query history endpoints and CRUD persistence per user/workspace/db. | +| `tests/test_invite_codes.py` | Workspace invitation PIN/code generation, validation, and acceptance. | +| `tests/test_knowledge_service.py` | `KnowledgeService`: verified Q&A, glossary, semantic catalog, and similarity scoring. | +| `tests/test_llm.py` | `OpenRouterProvider`: JSON fence stripping, response parsing, and error mapping. | +| `tests/test_m2_permissions_adversarial.py` | Adversarial security tests for RBAC permission checks across endpoints. | +| `tests/test_onboard_controller.py` | Onboarding wizard controller routes: auto-glossary, starter generation, connection testing. | +| `tests/test_openrouter.py` | OpenRouter provider requests, structured outputs, retries, and health checks. | +| `tests/test_permissions_and_workspace_settings.py` | RBAC role evaluation and custom workspace permission matrix overrides. | +| `tests/test_pgdatabase_users.py` | User persistence and CRUD operations in PostgreSQL. | +| `tests/test_phase4_e2e_suite.py` | End-to-end integration tests for query execution, repair loop, and catalog lookup. | +| `tests/test_query_db_scoping.py` | Strict scoping of query execution to specified workspace and database IDs. | +| `tests/test_query_feedback_verify.py` | User query feedback logging and verified Q&A promotion endpoints. | +| `tests/test_query_pipeline.py` | End-to-end pipeline execution (`run_query()`), schema linking, and self-repair. | +| `tests/test_query_stream.py` | Server-Sent Events (SSE) streaming query endpoint (`POST /api/query/stream`). | +| `tests/test_repair.py` | Isolated SQL self-repair loop: feedback generation, time and iteration bounds. | +| `tests/test_sample_data.py` | Sample database seeding, schema introspection, and pre-populated Q&A verification. | +| `tests/test_schema_link.py` | Table relevance scoring, FK expansion, schema compression, and confidence badge calculation. | +| `tests/test_semantic.py` | Automatic catalog inference from schema, suggestion merging, and table catalog filtering. | +| `tests/test_sqlvalidate.py` | AST-based static SQL parsing and safety validation against live database schemas. | +| `tests/test_structured_output.py` | JSON output schema validation (`SQL_SCHEMA`) and tolerant LLM payload extraction. | +| `tests/test_supabase_auth.py` | Integration with Supabase JWT authentication and OAuth sign-in. | +| `tests/test_system_controller.py` | System diagnostics, health check, and runtime configuration routes. | +| `tests/test_workspace_api_models.py` | Pydantic validation models for workspace API requests and responses. | +| `tests/test_workspace_defaults_empirical.py` | Default settings, role assignments, and capabilities for newly created workspaces. | +| `tests/test_workspace_lifecycle.py` | Workspace lifecycle management: creation, update, member roles, and deletion. | +| `tests/test_workspace_permission_defaults.py` | Verification of initial default permission matrices across Owner, Admin, and Member roles. | +| `tests/test_workspace_settings_api.py` | API endpoints for updating workspace settings and permission overrides. | +| `tests/test_workspace_settings_empirically.py` | Empirical testing of workspace settings resolution and capability evaluation. | + +Run: `pytest tests` from the repository root (with `backend/requirements.txt` installed). ## Everything else | Path | What it is | |---|---| -| `benchmarks/` | The Spider-based evaluation of schema-linking recall and end-to-end accuracy (`benchmarks/README.md` explains how to run it). | -| `docker-compose.yml`, `nginx/`, `*/DOCKERFILE*` | The Docker deployment: backend + frontend + nginx + PostgreSQL. | -| `data/` | Drop SQLite/DuckDB files here to use them from Docker (`/app/data/...`). | -| `.github/workflows/` | CI (tests/lint), CodeQL, OpenRouter-powered PR review bots. | -| `docs/` | These documents. | +| `benchmarks/` | Spider-based evaluation of schema-linking recall and end-to-end accuracy (`benchmarks/README.md`). | +| `docker-compose.yml`, `nginx/`, `backend/DOCKERFILE`, `frontend/DOCKERFILE`, `DOCKERFILE.render` | Docker deployment: backend + frontend + nginx + PostgreSQL. Port 8080. | +| `docker-compose.dev.yml` | Dev overrides — Vite HMR, source mounts. | +| `data/` | Drop SQLite/DuckDB files here to use from Docker (`/app/data/...`). | +| `.github/workflows/` | CI (tests/lint), CodeQL, OpenRouter-powered PR review workflows. | +| `postgres/` | PostgreSQL init scripts. | +| `nginx.conf.render`, `nginx/` | Nginx configs for production and Coolify. | +| `docs/` | Comprehensive user and developer documentation. | +| `memory/` | Product requirements and design docs. | diff --git a/docs/08-troubleshooting.md b/docs/08-troubleshooting.md index 855baa24..c833bafc 100644 --- a/docs/08-troubleshooting.md +++ b/docs/08-troubleshooting.md @@ -1,173 +1,62 @@ # 8. Troubleshooting — when something goes wrong -This chapter is organised by **symptom**. For each: what it probably means, -where to look in the code, and how to fix it. The first section needs no -technical knowledge; the second is for whoever maintains the deployment. +This chapter is organized by **symptom**. For each issue, it covers the root cause, where to check in the codebase, and how to resolve it. --- -## Part A — For users +## Part A — For Users -### "No OpenRouter API key is configured…" +### "No OpenRouter API Key configured" +- **Cause**: The server is missing the `OPENROUTER_API_KEY` environment variable. +- **Fix**: Set `OPENROUTER_API_KEY` in your `.env` file or environment and restart the backend server. -The AI has no key to work with. -**Fix:** open **Settings** (gear icon in the sidebar) → paste a key → -Save. Get a free key at https://aistudio.google.com/app/api-keys. +### "Authentication Required / 401 Unauthorized" +- **Cause**: Your session JWT cookie has expired or is invalid. +- **Fix**: Log out and log back in at `/login`. -### "…the API key is malformed or not valid" / "…invalid or expired" / "…may lack permission" +### "Workspace Access Denied / 403 Forbidden" +- **Cause**: You are attempting to perform an action (e.g., connect database, edit catalog) without the required role permission in the active workspace. +- **Fix**: Contact your workspace Owner or Admin to adjust your role in Workspace Settings (`/workspaces/[id]/settings`). -The key exists but Google refused it. The message tells you which flavour: +### "Connection Refused / Database Unreachable" +- **Cause**: Network connection to the target database failed, or SSRF protection blocked the host (`backend/app/database.py`). +- **Fix**: Ensure the database host is not loopback (`localhost`, `127.0.0.1`) when running inside Docker, and verify firewall rules allow traffic. -- **"rejected the request as invalid"** (HTTP 400) — usually a typo or a - truncated key. Re-copy it carefully. -- **"was not accepted — invalid or expired"** (HTTP 401) — the key was - revoked or expired. Generate a fresh one. -- **"denied access — may lack permission"** (HTTP 403) — the key is real but - restricted: the Generative Language API isn't enabled for its project, the - key has API restrictions, or your region/Workspace blocks access. Check the - key's settings in Google AI Studio. +### "Could not form a query — try rephrasing" +- **Cause**: The AI attempted up to 3 repair loops but could not generate valid SQL for your schema. +- **Fix**: Rephrase the question using exact column or table names, or add missing business definitions to the Data Catalog (`/api/catalog`). -**Fix:** generate a fresh key in Google AI Studio and re-enter it in -Settings. The moment a valid key is saved, everything works — no restart -needed (saving triggers `providers.reconfigure()` in -`backend/app/controllers/system.py`). - -### "The AI service is busy right now — please try again in a moment." - -Google's API returned a rate-limit or temporary server error, and BoloDB's -automatic retries (3 attempts with backoff) didn't get through. -**Fix:** wait a minute and re-ask. If it persists for hours, check Google's -status page or your quota in AI Studio (free-tier quotas are per-day). - -### "Could not form a query - try rephrasing" / an answer with Low confidence and an error - -The AI tried up to 3 times (including automatic self-repair) and still -couldn't produce SQL your database accepts. -**Fix:** rephrase with more specifics — name the thing you want counted or -summed the way it's called in your business. If a term keeps failing, add it -to the glossary during onboarding or verify a correct answer once — both -directly teach the system (see [chapter 6](06-learning-trust-and-confidence.md)). - -### "no matching rows - the question may not match your data" - -The query ran fine but found nothing. Often the filter is subtly off — e.g. -a date range with no data, or a category spelled differently in your data. -**Fix:** open the SQL toggle or hit Explain and check the filter values; ask -again naming an exact value you know exists. - -### The answer is wrong, but confidently so - -No validator can catch "runs fine, answers the wrong question". Your tools: -read the **restatement** and **assumptions**, hit **Explain**, and click -**"Something's wrong"** — that feedback is logged and keeps the wrong answer -out of the knowledge base. +--- -### "Only read-only SELECT queries are allowed" +## Part B — For Developers and Maintainers -You (or the AI) tried something that would modify data. That's the safety -guard working as intended — BoloDB never writes to your database. Code: -`backend/app/database.py` → `_readonly_violation()`. +### 1. Database Connection Encryption Errors +- **Symptom**: `Fernet InvalidToken` or failure decrypting stored connection credentials. +- **Root Cause**: `RECENT_CONNECTIONS_KEY` env variable changed or is not set properly across deployments. +- **Fix**: Ensure `RECENT_CONNECTIONS_KEY` remains static in your production `.env` file (`backend/app/pgdatabase/connections.py`). -### Can't connect to the database +### 2. Rate Limiting Limits Triggered (`slowapi`) +- **Symptom**: HTTP 429 Too Many Requests response. +- **Root Cause**: Client exceeded endpoint rate limits. +- **Fix**: Inspect `backend/app/ratelimit.py`. Ensure proxy headers (`X-Forwarded-For`) are forwarded correctly by Nginx/Traefik so all users don't share a single IP limit. -The connect screen translates the raw error into a friendly sentence -(`frontend/src/lib/data.ts` → `humanError()`): wrong password, unreachable -host, missing file, etc. If running in Docker and connecting to a database on -your own machine, note that `localhost` is automatically rewritten to reach -the host (`backend/app/database.py` → `connect()`). +### 3. OpenRouter API Errors & Taxonomy +- **Symptom**: `LLMError` in backend logs. +- **Code Reference**: `backend/app/llm.py` → `OpenRouterProvider`. +- **Fix**: + - For `LLMError` (authentication issue): Verify `OPENROUTER_API_KEY` validity at https://openrouter.ai/keys. + - For `LLMError` (context window / budget issue): Check `schema_link.py` budgets — table count budget may need adjustment. ---- +### 4. Stale-Chunk Streaming Interruptions +- **Symptom**: Chat interface hangs or streaming stops abruptly during query generation. +- **Code Reference**: `frontend/src/lib/api.ts` → `streamApiCall()`. +- **Fix**: Verify Nginx reverse proxy buffer settings (`proxy_buffering off;`) to allow SSE chunks to pass through immediately without buffering. -## Part B — For developers / maintainers - -### Golden rule: check the logs first - -- Every LLM failure logs a `WARNING` with the technical detail (the - user-facing message is intentionally vaguer). Search the backend log for - `LLM error during run_query` (from `controllers/query.py`) — the `detail` - field of the `LLMError` carries the HTTP status + response snippet from - Google (`backend/app/llm.py` → `complete()`). -- Every question and its outcome is appended to - `~/.bolodb/sessions/session-.jsonl` — question, SQL, - confidence, error, feedback verdicts, plus `tables_used` (exactly which - tables the AI saw, including schema-retry additions) and `attempts` (how - many generation rounds were needed). This is your replay log; high - `attempts` values are the production signal of linking misses. - -### Diagnosing a bad answer, step by step - -1. **Which tables did the AI see?** The `/api/query` response includes - `tables_used`. If the right table isn't there, the problem is schema - linking — see `link_relevant_tables()` scoring in - `backend/app/schema_link.py`, and enable DEBUG logging to see per-table - scores. Common cause: table/column names that share no words with how - users talk → fix by adding glossary terms. -2. **What was the AI actually told?** The full prompt is assembled in - `build_sql_system_prompt()` (`backend/app/llm.py`). Log or breakpoint - there to inspect it. -3. **Did self-repair fire?** Check `attempts` and `repaired` in the - response. `attempts: 3` + an `execution_error` means the model never - converged — usually a schema-linking miss (step 1) or a dialect quirk - (see `_DIALECT_HINTS` in `llm.py`). -4. **Was the SQL rejected before execution?** Validation errors ("Unknown - column …") come from `backend/app/sqlvalidate.py`; read the conservative - design note at the top of that file before "fixing" it. - -### The schema looks stale (renamed/added columns not showing) - -Schema introspection is cached per connection -(`backend/app/database.py` → `get_schema()`, `_schema_cache`). -Reconnecting the database refreshes it (or call `get_schema(user_id, -refresh=True)` where appropriate). - -### Everything AI-related fails instantly with no network calls - -`OpenRouterProvider.complete()` fails fast when `api_key` is empty — before any -HTTP. Check `GET /api/health`: it reports -`{"provider": {"name": "openrouter", "ok": false, "error": "No OpenRouter API key configured"}}` -in that state. Key resolution order: value saved in Settings beats the -`OPENROUTER_API_KEY` env var (`backend/app/config.py` → `load_config()`). - -### Google returned 404 for the model - -Model names get retired. The allowed list lives in one place — -`ALLOWED_MODELS` in `backend/app/config.py` — and the Settings dropdown in -`frontend/src/lib/components/Settings.svelte` mirrors it. Update both when -Google renames models. - -### The saved API key stopped working after files were moved/restored - -The key in `config.json` is encrypted with the per-install secret in -`~/.bolodb/.secret` (`backend/app/config.py` → `_fernet()`). If `.secret` is -deleted or the config is copied to another machine without it, the stored key -can no longer be decrypted — OpenRouter will reject it as invalid. -**Fix:** re-enter the key in Settings (it gets re-encrypted with the current -secret). When backing up `~/.bolodb`, keep `config.json` and `.secret` -together. - -### Old config from the multi-provider era - -Config files written before the OpenRouter-only switch (provider `ollama` / -`claude` / `openai` / `groq`) are migrated silently on load — -`load_config()` coerces provider+model and drops old vendor keys. Covered by -`tests/test_config.py::test_load_config_migrates_old_provider_config`. - -### Running the test suite +### 5. Running the Backend Test Suite ```bash pip install -r backend/requirements.txt -pytest tests -q +pytest tests -v ``` -All AI tests use a fake HTTP client (`tests/test_openrouter.py`) — the suite -needs **no network and no API key**. If you touched the pipeline, the most -valuable file to keep green is `tests/test_query_pipeline.py`. - -### Quick health checklist - -| Check | Expect | -|---|---| -| `GET /api/health` | `provider.name == "openrouter"`, `ok: true` when a valid key is set | -| `GET /api/state` | `config.api_keys_set.openrouter == "set"`, connection info | -| `~/.bolodb/config.json` | `{"provider": "openrouter", "model": "openrouter-2.5-…", "api_keys": {"openrouter": "…"}}` | -| Backend log | no repeated `OpenRouter transient HTTP …` warnings (would mean quota pressure) | +All unit tests use mock/fake OpenRouter providers and isolated database fixtures (`tests/test_openrouter.py`, `tests/test_query_pipeline.py`). diff --git a/docs/09-cost-optimisation.md b/docs/09-cost-optimisation.md index f714f288..49f4f444 100644 --- a/docs/09-cost-optimisation.md +++ b/docs/09-cost-optimisation.md @@ -1,76 +1,46 @@ -# 9. Cost optimisation — good answers without burning tokens +# 9. Cost Optimization — High Accuracy without Burning Tokens -Design goal, in order: **(1) the user's experience is never compromised, -(2) every avoidable token is avoided.** It turns out these rarely conflict — -most of what makes prompts cheaper (less irrelevant schema, structured -output, verified examples) also makes answers *better*. +BoloDB follows a clear design rule: **(1) the user's experience is never compromised, (2) every avoidable token is avoided.** -## Where AI cost comes from +Because OpenRouter bills per token, optimizing prompt construction and output format directly reduces operational cost while simultaneously improving model accuracy. -OpenRouter bills per token, input and output separately. For BoloDB, a -question's cost ≈ +--- -```text -(prompt: instructions + schema + glossary + examples + context) ← input tokens -+ (the model's reply: SQL + restatement + assumptions) ← output tokens -+ (invisible "thinking" tokens, if enabled) ← output tokens -× (number of attempts, if self-repair fires) -``` +## 1. Where AI Cost Comes From -Every mechanism below trims one of those factors. Each row names the code -that implements it. +Query generation cost is governed by: -## The mechanisms +$$\text{Cost} = \left( \text{Tokens}_{\text{prompt}} + \text{Tokens}_{\text{output}} \right) \times \text{Attempts}_{\text{repair}}$$ -| # | Mechanism | Effect | Code | +Where: +- **Prompt Tokens**: System instructions + compact schema + business glossary + semantic catalog rules + verified worked examples + conversation turns. +- **Output Tokens**: Structured JSON containing SQL, plain-English restatement, assumptions, and chart specification. +- **Attempts**: Number of self-repair retries (1 attempt for clean executions, up to 3 for repaired queries). + +--- + +## 2. Optimization Mechanisms + +| # | Mechanism | Effect | Implementation File | |---|---|---|---| -| 1 | **Schema linking** — only relevant tables are sent, not the whole database | Biggest single saving on input tokens; also *improves* accuracy | `backend/app/schema_link.py` → `link_relevant_tables()` ([chapter 4](04-schema-linking.md)) | -| 2 | **Compact schema rendering** — one dense line per table instead of CREATE TABLE dumps | ~3–5× fewer tokens per table | `schema_link.py` → `compact_schema()` | -| 3 | **Structured output** — the model is constrained to a JSON schema; it cannot ramble, apologise, or wrap answers in prose | Output tokens = exactly the fields we parse | `backend/app/llm.py` → `responseSchema` in `OpenRouterProvider._build_body()` | -| 4 | **Thinking off for simple tasks** — glossary, starters and Explain don't need reasoning tokens | Removes the invisible thinking cost on 3 of 4 operations | `llm.py` → `thinking_budget=0` call sites | -| 5 | **Thinking left dynamic for SQL generation** — the model spends reasoning only when the question is hard | Pay for reasoning only where it buys accuracy (deliberate: experience > cost) | `llm.py` → `generate_sql()` (no `thinking_budget` override) | -| 6 | **Output caps** — `maxOutputTokens: 4096`, temperature 0.1 | Bounds worst-case output cost | `llm.py` → `_build_body()` | -| 7 | **Bounded self-repair** — at most 3 attempts, 60-second budget | A stubborn failure can't loop forever | `backend/app/controllers/query.py` → `_MAX_ATTEMPTS`, `_MAX_SECONDS` | -| 8 | **Retry with backoff, never blindly** — only transient statuses retry; a bad API key fails once, immediately | No token/HTTP waste on hopeless calls | `llm.py` → `_RETRYABLE_STATUS`, `complete()` | -| 9 | **Verified-answer reuse** — similar past answers are injected as examples, so the model converges on attempt 1 instead of needing repairs | Fewer repair rounds over time; the system gets *cheaper as it's used* | `backend/app/knowledge.py` → `retrieve_similar()` | -| 10 | **Per-model budgets** — cheaper models get smaller prompts (fewer tables/samples/examples) | Cost scales with the model tier you chose | `schema_link.py` → `model_budget()` | -| 11 | **Schema introspection cache** — your database is profiled once per connection, not per question | No repeated DB scans; instant prompts | `backend/app/database.py` → `get_schema()` cache | -| 12 | **Two-stage linking pays for itself** — the shortlist pre-pass (big schemas only, 30+ tables) is a names-only prompt with thinking off; picking the right tables up-front avoids repair rounds that cost far more | One tiny call replaces 1–2 full retries | `llm.py` → `shortlist_tables()`, threshold in `controllers/query.py` | -| 13 | **Enrichment cap** — sample rows / known values are collected for at most 40 tables (largest first); structure is still collected for all | Bounded introspection cost on huge databases | `database.py` → `ENRICH_MAX` in `get_schema()` | - -## The one knob you control: the model - -In Settings (persisted via `backend/app/config.py`): - -| Model | Relative cost | Guidance | -|---|---|---| -| `deepseek-v4-flash` | lowest | Small schema, straightforward questions, high volume | -| `deepseek-v4-flash` (default) | low | The right answer for almost everyone | -| `deepseek-v4-flash` | highest | Big schemas, complex analytical questions | - -OpenRouter's API also has a **free tier** — for evaluation and light usage the -cost is simply zero. Current pricing/quotas: https://ai.google.dev/pricing. - -## What we deliberately do NOT do - -- **We don't cut schema below what the question needs.** FK parents and - junction tables are always included even past the table budget - (`_FK_EXTRA_SLOTS`) — a cheap prompt that produces a broken JOIN costs - more after one repair round than the tokens it saved. -- **We don't disable thinking for SQL generation.** Hard questions benefit; - dynamic thinking means simple questions barely spend any. -- **We don't retry non-transient errors.** An invalid key or a blocked - prompt fails fast with a clear message ([chapter 3](03-the-ai-layer-openrouter.md)). - -## Ideas for later (not implemented) - -- **Context caching** for the schema block — OpenRouter can cache repeated - prompt prefixes at a discount; worth it once per-database question volume - is high. Would slot into `OpenRouterProvider._build_body()`. -- **Semantic retrieval** (embeddings) instead of word-overlap for verified - answers — better example reuse at the same token cost. Would replace - `_similarity()` in `knowledge.py`. - -A [Spider](https://github.com/taoyds/spider)-based benchmark for measuring -linking recall and execution accuracy before shipping prompt changes now -exists — see `benchmarks/README.md`. +| 1 | **Schema Linking** | Sends only relevant tables instead of dumping full database schema | `backend/app/schema_link.py` → `link_relevant_tables()` | +| 2 | **Compact Schema Rendering** | Compresses table definitions into 1 line per table | `schema_link.py` → `compact_schema()` | +| 3 | **Structured Output Contract** | Forces exact JSON schema (`SQL_SCHEMA`); eliminates rambling prose | `backend/app/llm.py` → `OpenRouterProvider` | +| 4 | **Streaming Responses** | Streams chunks in real time, giving immediate user feedback without polling overhead | `backend/app/routes/query.py` & `frontend/src/lib/api.ts` | +| 5 | **Bounded Self-Repair** | Enforces max 3 repair attempts and 60-second execution deadline | `backend/app/controllers/query.py` → `_MAX_ATTEMPTS` | +| 6 | **PostgreSQL Verified Answer Reuse** | Injects verified Q&A pairs as few-shot prompt examples, maximizing first-pass success | `backend/app/pgdatabase/knowledge.py` → `retrieve_similar()`, `set_glossary()`, `set_catalog()` | +| 7 | **Two-Stage Linking for Large Schemas** | Pre-filters candidate tables via cheap names-only shortlist on schemas with 30+ tables | `llm.py` → `shortlist_tables()` | +| 8 | **Schema Introspection Caching** | Caches table introspections per workspace database connection | `backend/app/database.py` → `get_schema()` | + +--- + +## 3. Deliberate Trade-Offs + +- **No Over-Trimming**: Foreign key parents and junction tables are retained even beyond the initial table budget (`_FK_EXTRA_SLOTS`) to avoid broken SQL JOINs. +- **Fail Fast**: Non-retryable errors (e.g., authentication or permissions) fail immediately to avoid wasting tokens. + +--- + +## 4. Benchmarking Cost and Accuracy + +The benchmark suite (`benchmarks/README.md`) measures schema linking recall, execution accuracy, and average token consumption against standard benchmark datasets like Spider. diff --git a/docs/10-auth-and-workspaces.md b/docs/10-auth-and-workspaces.md new file mode 100644 index 00000000..be38ffc4 --- /dev/null +++ b/docs/10-auth-and-workspaces.md @@ -0,0 +1,84 @@ +# 10. Authentication and Workspaces + +BoloDB is built as a multi-tenant web application. All data—connections, query history, saved queries, dashboards, and the knowledge base—is isolated per **workspace**. + +--- + +## 1. Authentication + +Authentication is handled via JWT tokens issued by the backend server (`backend/app/routes/auth.py` and `backend/app/controllers/auth.py`). + +### Auth Methods +- **Email + Password**: Users register via `/api/auth/signup` and authenticate via `/api/auth/login`. Password hashes are stored securely using passlib/bcrypt. +- **Google OAuth**: Google authentication flow produces a verified OAuth token handled at `/api/auth/supabase-google`. +- **Email Verification & Password Reset**: Handled via token generation in `backend/app/services/email_verification.py` and dispatched using the Resend API service (`backend/app/services/email.py`). + +### Token Management & Cookies +- Upon successful authentication, the server sets secure HTTP-only cookies containing the JWT access and refresh tokens. +- `backend/app/dependencies.py` exposes `get_current_user`, which validates the token on incoming API calls. +- Centralized secrets management (`backend/app/secrets.py`) retrieves secrets like `JWT_SECRET`, `RESEND_API_KEY`, and `FRONTEND_URL` from the environment. + +--- + +## 2. Workspace Multi-Tenancy + +A **Workspace** is the primary boundary of data isolation. Each workspace contains: +- **Database Connections**: Connections registered within the workspace context (encrypted at rest). +- **Knowledge Base**: Verified Q&A, business glossary, and semantic layer entries (`backend/app/pgdatabase/knowledge.py`). +- **Dashboards & Saved Queries**: Custom dashboards with ECharts visual panels. +- **Activity Log**: Workspace audit log (`backend/app/models/activity.py`). + +### Context Propagation +Client requests include `X-Workspace-Id` and `X-Db-Id` headers. The FastAPI dependencies `get_current_workspace()` and `get_current_db_id()` in `backend/app/dependencies.py` ensure: +1. The requested workspace exists and the authenticated user is an active member of that workspace. +2. The active target database connection context (`X-Db-Id`) is validated and propagated to database services and controllers. + +--- + +## 3. Role-Based Access Control (RBAC) + +BoloDB uses a granular permission registry defined in `backend/app/permissions.py`. + +### Roles +Every workspace member holds one of three roles: +1. **Owner**: Full administrative control over workspace settings, members, permissions, and database connections. +2. **Admin**: Can manage connections, edit the semantic catalog, invite members, and build dashboards. +3. **Member**: Can execute queries, view dashboards, view schemas, and save queries. + +### Permission Registry Matrix + +The system defines 21 fine-grained capabilities across 7 resources: + +| Resource | Capability Key | Description | Default Owner | Default Admin | Default Member | +|---|---|---|---|---|---| +| **members** | `members.view` | View workspace member list and member details | Yes | Yes | Yes | +| | `members.invite` | Invite new members to join the workspace | Yes | Yes | No | +| | `members.update_role` | Change roles of workspace members | Yes | Yes | No | +| | `members.remove` | Remove members from the workspace | Yes | Yes | No | +| **connections** | `connections.view` | View configured database connections | Yes | Yes | Yes | +| | `connections.manage` | Create, edit, or delete database connections | Yes | Yes | No | +| | `connections.view_schema` | View schema and metadata for database connections | Yes | Yes | Yes | +| **catalog** | `catalog.view` | View data catalog, verified Q&A, and metrics | Yes | Yes | Yes | +| | `catalog.manage` | Create or update data catalog definitions | Yes | Yes | No | +| **dashboards** | `dashboards.view` | View dashboards and visualization panels | Yes | Yes | Yes | +| | `dashboards.create` | Create new dashboards and panels | Yes | Yes | No | +| | `dashboards.manage` | Edit or delete existing dashboards | Yes | Yes | No | +| **queries** | `queries.execute` | Execute natural language and SQL queries | Yes | Yes | Yes | +| | `queries.explain` | Generate query explanations and execution plans | Yes | Yes | Yes | +| | `queries.save` | Save queries for workspace access | Yes | Yes | Yes | +| | `queries.delete_saved` | Delete saved queries | Yes | Yes | No | +| **activity** | `activity.view` | View workspace activity logs | Yes | Yes | No | +| | `activity.export` | Export workspace activity logs | Yes | Yes | No | +| **workspace_management** | `workspace.view` | View workspace details and configuration | Yes | Yes | Yes | +| | `workspace.update` | Update workspace basic profile details | Yes | Yes | No | +| | `workspace.settings` | Manage workspace defaults and role permission matrix | Yes | Yes | No | + +Custom role overrides can be saved per workspace in `WorkspaceSettings` (`backend/app/models/workspace_settings.py`). + +--- + +## 4. Member Invites & Onboarding Flow + +1. Workspace owners/admins generate an invitation link or email invite. +2. An invitation pin/token is created (`backend/app/pgdatabase/otp.py`). +3. Invited users accept the invitation during registration or via their workspace switcher in the app. diff --git a/docs/11-dashboards-and-charts.md b/docs/11-dashboards-and-charts.md new file mode 100644 index 00000000..ee69d13c --- /dev/null +++ b/docs/11-dashboards-and-charts.md @@ -0,0 +1,67 @@ +# 11. Dashboards and Charts + +BoloDB provides built-in visualization and dashboarding capabilities, allowing users to save queries and compile them into interactive dashboards powered by **Apache ECharts**. + +--- + +## 1. Visualization & Chart Type Inference + +When BoloDB executes a natural language query, the OpenRouter AI model infers the best visualization format alongside generating the SQL (`backend/app/llm.py`). + +### Supported Chart Types +- **`bar`**: Ideal for categorical comparisons (e.g., revenue per category). +- **`line`**: Ideal for continuous time-series data (e.g., daily active users). +- **`area`**: Ideal for cumulative time-series data. +- **`pie`**: Ideal for proportional breakdown of a single metric. +- **`number`**: Ideal for single scalar metric KPIs (e.g., total sales count). +- **`table`**: Default fallback tabular view for multi-column relational data. + +### Chart Specification Schema +The AI returns a structured chart specification object as part of `SQL_SCHEMA`: + +```json +{ + "chart": { + "type": "bar", + "x_column": "category_name", + "y_columns": ["total_revenue"], + "title": "Revenue by Product Category" + } +} +``` + +The frontend components `frontend/src/lib/components/ChartPanel.svelte`, `frontend/src/lib/components/charts/ResultChart.svelte`, and `frontend/src/lib/components/charts/ChartCard.svelte` consume this specification (using helper utilities from `frontend/src/lib/components/charts/chartUtils.ts`) to dynamic initialize ECharts instances tailored to the data shape. + +--- + +## 2. Saved Queries + +Users can save any query result directly from an Answer Card (`frontend/src/lib/components/AnswerCard.svelte`) using `frontend/src/lib/components/SaveQueryDialog.svelte`. + +### Backend Implementation +- **Route**: `backend/app/routes/saved_queries.py` +- **Database Service**: `backend/app/pgdatabase/saved_queries.py` +- **Data Model**: `backend/app/models/saved_query.py` (`SavedQuery`) + +Saved queries capture: +- SQL query string +- Natural language question & restatement +- Selected chart type and configuration parameters +- Workspace and Database ID scoping + +--- + +## 3. Dashboards & Panels + +Dashboards aggregate multiple saved queries or custom query panels into grid layouts. + +### Architecture & Endpoints +- **Routes**: `backend/app/routes/dashboards.py` +- **Controller**: `backend/app/controllers/dashboards.py` +- **Database Service**: `backend/app/pgdatabase/dashboards.py` +- **Models**: `backend/app/models/dashboard.py` (`Dashboard`, `DashboardPanel`) + +### Key Operations +1. **Dashboard CRUD**: Create, read, update title/description, and delete workspace dashboards. +2. **Panel Batch Update**: `POST /api/dashboards/{id}/panels/batch` allows layout changes (grid coordinates `x, y, w, h`) to be saved atomically as users resize or drag panels in `frontend/src/lib/components/DashboardEditor.svelte`. +3. **Live Query Execution**: Panels execute their stored SQL against the current workspace database connection on load or manual refresh. diff --git a/docs/12-semantic-layer.md b/docs/12-semantic-layer.md new file mode 100644 index 00000000..466b65ff --- /dev/null +++ b/docs/12-semantic-layer.md @@ -0,0 +1,81 @@ +# 12. The Semantic Layer + +The **Semantic Layer** bridges the gap between raw database schemas and business domain concepts. It ensures that business terminology, complex metrics, and implicit table relationships are correctly interpreted by the AI during SQL generation. + +--- + +## 1. Core Concepts & Data Models + +The semantic layer is stored in PostgreSQL via `KnowledgeService` (`set_catalog` / `get_catalog` in `backend/app/pgdatabase/knowledge.py`) and modeled in `backend/app/models/catalog.py`. + +### 1. Metric Definitions (`CatalogMetric`) +Pre-defined SQL expressions for business metrics so the AI doesn't miscalculate key KPIs. +- *Example*: `ARR = SUM(annual_contract_value) WHERE status = 'active'` + +### 2. Join Paths (`CatalogJoin`) +Explicit join rules between tables when foreign keys are missing or when non-standard join conditions are required. +- *Example*: `orders.customer_id = customers.id AND customers.is_deleted = false` + +### 3. Synonyms (`CatalogSynonym`) +Mapping business slang or alternative names to actual database table or column names. +- *Example*: `"client"` → `customers`, `"gross sales"` → `orders.total_amount` + +### 4. Value Mappings (`CatalogValueMapping`) +Translating user concepts into specific column value filters. +- *Example*: `"VIP customers"` → `customers.tier = 'VIP'` or `customers.total_spend > 10000` + +### 5. Column Descriptions (`CatalogColumn`) +Human-readable context and notes attached to specific database columns. + +--- + +## 2. Automatic Inference & Schema Enrichment + +BoloDB automatically assists in building the semantic layer using `backend/app/semantic.py` and `backend/app/llm.py`: + +1. **`suggest_from_schema(schema)`**: Inspects foreign keys, column names, and sample values to propose join paths and value mappings deterministically. +2. **`suggest_catalog(provider, schema_text)`**: Uses LLM enrichment (`backend/app/llm.py`) to generate descriptions, metrics, synonyms, and business labels from schema text. +3. **`merge_catalog_suggestions(existing, suggested)`**: Merges automatic schema suggestions with human overrides. +4. **`filter_catalog(catalog, linked_tables)`**: Scopes the semantic catalog so only relevant metrics, joins, and synonyms are included in the prompt for the tables selected by schema linking. + +--- + +## 3. How the AI Uses the Semantic Layer + +During Step 1 and 2 of the question pipeline (`backend/app/controllers/query.py`), BoloDB fetches the workspace's semantic catalog for the active database connection: + +```text +Question ──▶ Schema Linking (picks relevant tables) + │ + ▼ + filter_catalog() (filters metrics/joins for selected tables) + │ + ▼ + Prompt Assembly (appends Semantic Rules block to OpenRouter prompt) + │ + ▼ + LLM generates accurate, domain-aware SQL +``` + +### Prompt Integration Example +In `backend/app/llm.py`, the semantic context is injected directly into the prompt: + +```text +[SEMANTIC RULES & METRICS] +- When user asks for "ARR", use expression: SUM(annual_contract_value) WHERE status = 'active' +- Always join orders to customers using: orders.customer_id = customers.id +- "VIP customer" means: tier = 'VIP' +``` + +--- + +## 4. Semantic Catalog Management + +Workspace admins and owners can view, edit, and AI-suggest the semantic layer via the Data Catalog endpoints (`GET /api/catalog`, `POST /api/catalog`, `POST /api/catalog/suggest`), handled by `backend/app/routes/catalog.py` and `backend/app/controllers/catalog.py`. + +### Request Context Headers +All catalog endpoints require workspace and database context propagation headers: +- `X-Workspace-Id`: Target workspace identifier. +- `X-Db-Id`: Target database connection identifier. + +Saving catalog entries executes `set_catalog()` on `KnowledgeService` (`backend/app/pgdatabase/knowledge.py`), replacing or updating stored entries for the active workspace and database. Changes take effect immediately for all subsequent queries. diff --git a/docs/README.md b/docs/README.md index 5bbd5738..e3c716e6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,44 +1,31 @@ # BoloDB Documentation -Welcome! These docs explain **what BoloDB does, how it does it, and where in -the code each piece lives**. They are written so that: +Welcome! These docs explain **what BoloDB does, how it does it, and where in the code each piece lives**. -- a **non-technical reader** can follow every chapter top-to-bottom and - understand the product, and -- a **developer** (or a curious founder) can jump from any paragraph straight - into the code, because every mechanism is annotated with the file — and - usually the exact function — where it happens. +- A **non-technical reader** can follow every chapter top-to-bottom and understand the product architecture. +- A **developer** can jump from any paragraph straight into the codebase, annotated with file and function references. -## How to read these docs - -If you are new, read chapters 1 → 2 first. After that, each chapter stands on -its own. Code pointers look like this: - -> `backend/app/llm.py` → `generate_sql()` - -which means: open that file, find that function. +--- ## Chapters | # | Chapter | What it answers | |---|---------|-----------------| -| 1 | [What is BoloDB?](01-what-is-bolodb.md) | The product in plain language. What happens when you connect a database and ask a question. | -| 2 | [How a question becomes an answer](02-how-a-question-becomes-an-answer.md) | The full pipeline, step by step, with the exact code location for every step. **The most important chapter.** | -| 3 | [The AI layer (OpenRouter)](03-the-ai-layer-openrouter.md) | Everything about the AI: what is sent to Google, which models we use, API keys, retries, errors, and how to add another AI vendor later. | -| 4 | [Schema linking — choosing tables](04-schema-linking.md) | How BoloDB decides which tables the AI gets to see, and why that makes answers cheaper *and* more accurate. | -| 5 | [Safety, validation and self-repair](05-safety-validation-and-self-repair.md) | The three safety nets: read-only enforcement, SQL validation before execution, and the automatic repair loop. | -| 6 | [Learning, trust and confidence](06-learning-trust-and-confidence.md) | How BoloDB learns from your confirmations, and how the High/Medium/Low confidence badge is computed. | -| 7 | [File map](07-file-map.md) | Every file in the repository with a one-line description. Use it as an index. | -| 8 | [Troubleshooting](08-troubleshooting.md) | "Something is wrong" → what it probably is → where to look in the code → how to fix it. | -| 9 | [Cost optimisation](09-cost-optimisation.md) | Where AI costs come from and every mechanism BoloDB uses to keep them low without hurting answer quality. | - -## The one-paragraph summary - -BoloDB lets anyone ask questions about a SQL database in plain English. When -you ask a question, BoloDB picks the handful of tables that matter -(chapter 4), sends the question plus that trimmed schema to OpenRouter -(chapter 3), checks the SQL that comes back before running it and -automatically fixes it if it's broken (chapter 5), runs it **read-only** -against your database, and shows the results with a plain-English restatement -and a confidence level (chapter 6). Every answer you confirm as correct is -remembered and makes future answers better. +| 1 | [What is BoloDB?](01-what-is-bolodb.md) | Multi-tenant product introduction, security model, and 30-second architecture overview. | +| 2 | [How a question becomes an answer](02-how-a-question-becomes-an-answer.md) | The full execution pipeline step by step. **The most important chapter.** | +| 3 | [The AI layer (OpenRouter)](03-the-ai-layer-openrouter.md) | OpenRouter provider (`deepseek/deepseek-v4-flash`), structured JSON outputs, streaming SSE, and prompt assembly. | +| 4 | [Schema linking — choosing tables](04-schema-linking.md) | How table scoring, FK expansion, and two-stage linking optimize context windows. | +| 5 | [Safety, validation and self-repair](05-safety-validation-and-self-repair.md) | SSRF defense, read-only AST safety, static SQL validation, statement timeouts, and self-repair. | +| 6 | [Learning, trust and confidence](06-learning-trust-and-confidence.md) | PostgreSQL knowledge base, Jaccard/sequence similarity, and confidence badges. | +| 7 | [File map](07-file-map.md) | Complete codebase index mapping backend, frontend, tests, and configurations. | +| 8 | [Troubleshooting](08-troubleshooting.md) | Symptom-based troubleshooting guide for users and maintainers. | +| 9 | [Cost optimisation](09-cost-optimisation.md) | Token optimization mechanisms, structured output contracts, and budget controls. | +| 10 | [Authentication and Workspaces](10-auth-and-workspaces.md) | JWT auth, Google OAuth, workspace multi-tenancy, and RBAC permission matrix. | +| 11 | [Dashboards and Charts](11-dashboards-and-charts.md) | Interactive ECharts visualizations, saved queries, and dashboard panel CRUD. | +| 12 | [The Semantic Layer](12-semantic-layer.md) | Business metrics, join rules, synonyms, value mappings, and domain context injection. | + +--- + +## The Summary + +BoloDB lets anyone talk to their database in plain English. Built as a multi-tenant FastAPI + SvelteKit web application, it translates questions into SQL using OpenRouter AI (`deepseek/deepseek-v4-flash`), validates and executes queries safely in read-only mode, and renders interactive ECharts visualizations and restatements. Every request is scoped to a workspace and database connection via `X-Workspace-Id` and `X-Db-Id` headers. Every confirmed query enriches the workspace's PostgreSQL knowledge base to improve future accuracy. diff --git a/frontend/DOCKERFILE b/frontend/DOCKERFILE index adf9cb5a..79fa4c63 100644 --- a/frontend/DOCKERFILE +++ b/frontend/DOCKERFILE @@ -10,7 +10,7 @@ WORKDIR /app # ── 1. Install dependencies (cached layer) ── COPY package.json package-lock.json* ./ -RUN npm ci +RUN npm install # ── 2. Copy source & build ── COPY . . diff --git a/frontend/DOCKERFILE.dev b/frontend/DOCKERFILE.dev index b9ce5a3f..908aae1d 100644 --- a/frontend/DOCKERFILE.dev +++ b/frontend/DOCKERFILE.dev @@ -8,7 +8,7 @@ WORKDIR /app # Install dependencies (cached layer) COPY package.json package-lock.json* ./ -RUN npm ci +RUN npm install # Copy source files COPY . . @@ -22,4 +22,4 @@ EXPOSE 5173 # dependencies missing whenever package.json gains a new one — surfacing as # ERR_MODULE_NOT_FOUND for a package that installs fine everywhere else. # Reconcile against the lockfile on every start so the volume can't go stale. -CMD ["sh", "-c", "npm ci && npm run dev -- --host 0.0.0.0"] +CMD ["sh", "-c", "npm install && npm run dev -- --host 0.0.0.0"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ce23d40c..c7354eff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,10 +9,12 @@ "version": "0.0.1", "dependencies": { "driver.js": "^1.8.0", + "echarts": "^5.6.0", "gsap": "^3.15.0", "layerchart": "^2.0.1", "lenis": "^1.3.25", "posthog-js": "^1.404.1", + "svelte-echarts": "^1.0.0", "typesafe-i18n": "^5.27.1" }, "devDependencies": { @@ -1523,6 +1525,22 @@ "integrity": "sha512-+8/IO7h1v14IzWh2GP60N7T3PFZweXwdn5e5POuxRSBoCYUojsBxzqawPeXh3YZIibRy7EehYNEyxe7slwwtdg==", "license": "MIT" }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/enhanced-resolve": { "version": "5.21.6", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", @@ -2420,6 +2438,16 @@ "typescript": ">=5.0.0" } }, + "node_modules/svelte-echarts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/svelte-echarts/-/svelte-echarts-1.0.0.tgz", + "integrity": "sha512-o0VgdxGJt+Km+IrxNo35qTJFqDsvj65p7JwAlIrk7CsWjBKKniJaUV6hKbMDNf0S34Su0idWbWEdymJNPX3anA==", + "license": "MIT", + "peerDependencies": { + "echarts": "^5.0.0", + "svelte": ">=5" + } + }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", @@ -2637,6 +2665,21 @@ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "license": "MIT" + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } } } diff --git a/frontend/package.json b/frontend/package.json index ea9c532f..54fade46 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,10 +30,12 @@ }, "dependencies": { "driver.js": "^1.8.0", + "echarts": "^5.6.0", "gsap": "^3.15.0", "layerchart": "^2.0.1", "lenis": "^1.3.25", "posthog-js": "^1.404.1", + "svelte-echarts": "^1.0.0", "typesafe-i18n": "^5.27.1" } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 26926619..e5e34a07 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2,6 +2,27 @@ import type { StreamEvent } from "$lib/types"; +/** + * Turn a FastAPI error body's `detail` into a readable string. `detail` is + * usually a plain string, but 422 validation errors return it as an array of + * `{ loc, msg, type }` objects — passing that straight to `new Error()` renders + * as the useless "[object Object]". + */ +function extractDetail(data: any, status: number): string { + const detail = data?.detail; + if (typeof detail === "string" && detail) return detail; + if (Array.isArray(detail)) { + const msg = detail + .map((d) => (typeof d === "string" ? d : d?.msg)) + .filter(Boolean) + .join("; "); + if (msg) return msg; + } else if (detail && typeof detail === "object" && detail.msg) { + return detail.msg; + } + return `Request failed: ${status}`; +} + export async function apiCall( path: string, body?: unknown, @@ -10,15 +31,30 @@ export async function apiCall( const opts: RequestInit = { method: method || (body ? "POST" : "GET"), }; + const activeWorkspaceId = + typeof window !== "undefined" + ? localStorage.getItem("bolodb_active_workspace_id") + : null; + const activeDbId = + typeof window !== "undefined" && activeWorkspaceId + ? localStorage.getItem(`bolodb_active_db_id_${activeWorkspaceId}`) + : null; + const headers: Record = {}; + if (activeWorkspaceId) headers["X-Workspace-Id"] = activeWorkspaceId; + if (activeDbId) headers["X-Db-Id"] = activeDbId; + if (body) { - opts.headers = { "Content-Type": "application/json" }; + headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(body); } + if (Object.keys(headers).length > 0) { + opts.headers = headers; + } opts.credentials = "include"; const r = await fetch(path, opts); const data = await r.json().catch(() => ({})); if (!r.ok) { - const error: any = new Error(data.detail || `Request failed: ${r.status}`); + const error: any = new Error(extractDetail(data, r.status)); error.status = r.status; throw error; } @@ -46,16 +82,30 @@ export async function streamApiCall( signal?: AbortSignal, ): Promise { try { + const activeWorkspaceId = + typeof window !== "undefined" + ? localStorage.getItem("bolodb_active_workspace_id") + : null; + const activeDbId = + typeof window !== "undefined" && activeWorkspaceId + ? localStorage.getItem(`bolodb_active_db_id_${activeWorkspaceId}`) + : null; + const headers: Record = { + "Content-Type": "application/json", + }; + if (activeWorkspaceId) headers["X-Workspace-Id"] = activeWorkspaceId; + if (activeDbId) headers["X-Db-Id"] = activeDbId; + const response = await fetch(path, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, credentials: "include", body: JSON.stringify(body), signal, }); if (!response.ok) { const data = await response.json().catch(() => ({})); - throw new Error(data.detail || `Request failed: ${response.status}`); + throw new Error(extractDetail(data, response.status)); } if (!response.body) throw new Error("Streaming not supported"); @@ -185,6 +235,201 @@ export async function supabaseGoogleLogin(accessToken: string): Promise { }); } +export async function updateProfile(fields: any): Promise { + return apiCall("/api/auth/me", fields, "PATCH"); +} + +// --- Workspaces --- + +export interface WorkspaceSettings { + workspace_id?: string; + default_invite_role?: "member" | "admin" | string; + invite_expiry_days?: number; + activity_retention_days?: number; + role_permissions?: Record; + resolved_matrix?: Record>; + /** The matrix with nothing customised, so the UI can mark what is default. */ + default_matrix?: Record>; +} + +export async function getWorkspaceSettings( + workspaceId: string, +): Promise { + return apiCall(`/api/workspaces/${workspaceId}/settings`); +} + +export async function updateWorkspaceSettings( + workspaceId: string, + data: Partial, +): Promise { + return apiCall(`/api/workspaces/${workspaceId}/settings`, data, "PATCH"); +} + +export async function createWorkspace(name: string): Promise { + return apiCall("/api/workspaces", { name }); +} + +export async function updateWorkspace(id: string, name: string): Promise { + return apiCall(`/api/workspaces/${id}`, { name }, "PATCH"); +} + +export async function deleteWorkspace(id: string): Promise { + return apiCall(`/api/workspaces/${id}`, undefined, "DELETE"); +} + +export async function leaveWorkspace(id: string): Promise { + return apiCall(`/api/workspaces/${id}/leave`, undefined, "POST"); +} + +export async function updateConnectionAlias( + id: string, + aliasName: string, +): Promise { + return apiCall(`/api/connections/${id}`, { alias_name: aliasName }, "PATCH"); +} + +export async function getWorkspaceMembers(id: string): Promise { + return apiCall(`/api/workspaces/${id}/members`); +} + +export async function inviteWorkspaceMember( + id: string, + email: string, + role: string, +): Promise { + return apiCall(`/api/workspaces/${id}/members`, { email, role }, "POST"); +} + +/** Invite many people at once; the response reports a status per address. */ +export async function bulkInviteMembers( + id: string, + emails: string[], + role: string, +): Promise { + return apiCall( + `/api/workspaces/${id}/members/bulk`, + { emails, role }, + "POST", + ); +} + +export async function transferOwnership( + workspaceId: string, + userId: string, +): Promise { + return apiCall( + `/api/workspaces/${workspaceId}/transfer-ownership`, + { user_id: userId }, + "POST", + ); +} + +export async function updateWorkspaceMemberRole( + workspaceId: string, + userId: string, + role: string, +): Promise { + return apiCall( + `/api/workspaces/${workspaceId}/members/${userId}`, + { role }, + "PUT", + ); +} + +export async function removeWorkspaceMember( + workspaceId: string, + userId: string, +): Promise { + return apiCall( + `/api/workspaces/${workspaceId}/members/${userId}`, + undefined, + "DELETE", + ); +} + +/** Invites sent from this workspace that are still awaiting acceptance. */ +export async function getPendingInvites(workspaceId: string): Promise { + return apiCall(`/api/workspaces/${workspaceId}/invites`); +} + +export async function rescindInvite( + workspaceId: string, + inviteId: string, +): Promise { + return apiCall( + `/api/workspaces/${workspaceId}/invites/${inviteId}`, + undefined, + "DELETE", + ); +} + +export async function resendInvite( + workspaceId: string, + inviteId: string, +): Promise { + return apiCall( + `/api/workspaces/${workspaceId}/invites/${inviteId}/resend`, + undefined, + "POST", + ); +} + +export async function acceptWorkspaceInvite(token: string): Promise { + return apiCall(`/api/workspaces/invites/${token}/accept`, undefined, "POST"); +} + +export async function getWorkspaceActivity( + workspaceId: string, + page: number = 1, +): Promise { + const limit = 50; + const offset = (page - 1) * limit; + return apiCall( + `/api/workspaces/${workspaceId}/activity?limit=${limit}&offset=${offset}`, + ); +} + +/** + * Download the workspace activity log as CSV. + * + * Goes through fetch rather than a plain link because the endpoint needs the + * workspace header and session cookie that `apiCall` attaches. + */ +export async function downloadWorkspaceActivity( + workspaceId: string, +): Promise { + const headers: Record = { "X-Workspace-Id": workspaceId }; + const r = await fetch(`/api/workspaces/${workspaceId}/activity/export`, { + credentials: "include", + headers, + }); + if (!r.ok) { + const data = await r.json().catch(() => ({})); + const error: any = new Error(extractDetail(data, r.status)); + error.status = r.status; + throw error; + } + const blob = await r.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `activity-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +// --- Databases --- + +export async function listDatabases(): Promise { + return apiCall("/api/databases"); +} + +export async function removeDatabase(): Promise { + return apiCall("/api/disconnect", undefined, "POST"); +} + /** Convert API rows (array of objects) to 2D string arrays for ResultTable */ export function rowsToArrays( columns: string[], diff --git a/frontend/src/lib/appState.svelte.ts b/frontend/src/lib/appState.svelte.ts index a354210f..ee538ee4 100644 --- a/frontend/src/lib/appState.svelte.ts +++ b/frontend/src/lib/appState.svelte.ts @@ -1,6 +1,6 @@ import { trustFor, schemaObjToDisplay } from "$lib/data"; -import { apiCall } from "$lib/api"; -import type { DbInfo, SchemaTable, Toast } from "$lib/types"; +import { apiCall, getConversations, listDatabases } from "$lib/api"; +import type { Conversation, DbInfo, SchemaTable, Toast } from "$lib/types"; import { goto } from "$app/navigation"; import { browser } from "$app/environment"; @@ -14,31 +14,32 @@ class AppState { theme = $state("dark"); openrouterReady = $state(false); activeConversationId = $state(null); + // Set while a database switch is in flight, so any screen showing results can + // put up a loading state — the switch is triggered from the shared shell. + switchingDatabase = $state(false); + workspaces = $state([]); + activeWorkspace = $state(null); + invites = $state([]); tourCompleted = $state(false); - // True while a freshly connected user is walking the onboarding flow — - // stops /onboard's has_knowledge redirect from kicking them out (sample - // databases ship with seeded knowledge, so has_knowledge alone can't - // distinguish "already onboarded" from "just connected"). - onboardingActive = $state(false); + // tourCompleted = $state(false); + + /** + * The workspace's saved databases and conversations, held here rather than in + * the components that show them. Both the sidebar and the database switcher + * are re-created on every route change, and re-fetching each time made moving + * between chat and dashboards flash empty lists over a page that hadn't + * actually changed. + */ + databases = $state([]); + conversations = $state([]); + conversationsLoaded = $state(false); + databasesLoaded = $state(false); constructor() { if (typeof window !== "undefined") { const stored = localStorage.getItem("bolodb_theme"); // Normalize legacy theme names to the two-theme system (dark / light) this.theme = stored === "dark" ? "dark" : stored ? "light" : "dark"; - // Survive a page reload mid-onboarding: sample DBs ship with seeded - // knowledge, so without this /onboard would bounce a refreshed user - // straight to /chat before they finished. - this.onboardingActive = - sessionStorage.getItem("bolodb_onboarding_active") === "1"; - } - } - - private setOnboardingActive(active: boolean) { - this.onboardingActive = active; - if (typeof window !== "undefined") { - if (active) sessionStorage.setItem("bolodb_onboarding_active", "1"); - else sessionStorage.removeItem("bolodb_onboarding_active"); } } @@ -59,7 +60,104 @@ class AppState { return trustFor(this.verifiedCount).key; } + /** + * Invite ids already seen, so a refresh only announces genuinely new ones. + * `null` until the first load — the invitations waiting when you sign in are + * shown by the bell, not thrown at you as a toast. + */ + private seenInviteIds: Set | null = null; + + async loadWorkspaces() { + try { + const previousWorkspaceId = this.activeWorkspace?.id; + this.workspaces = await apiCall("/api/workspaces"); + this.invites = await apiCall("/api/workspaces/invites/me"); + this.announceNewInvites(); + const stored = localStorage.getItem("bolodb_active_workspace_id"); + if (stored && this.workspaces.find((w: any) => w.id === stored)) { + this.activeWorkspace = this.workspaces.find( + (w: any) => w.id === stored, + ); + } else if (this.workspaces.length > 0) { + this.activeWorkspace = this.workspaces[0]; + localStorage.setItem( + "bolodb_active_workspace_id", + this.activeWorkspace.id, + ); + } else { + this.activeWorkspace = null; + localStorage.removeItem("bolodb_active_workspace_id"); + } + if (this.activeWorkspace?.id !== previousWorkspaceId) { + this.resetWorkspaceData(); + } + } catch (e) { + console.error("Failed to load workspaces:", e); + } + } + + /** + * Load the workspace's saved databases. `force` refetches even when a list is + * already held — used after connecting, renaming or removing one. + */ + async loadDatabases(force = false) { + if (this.databasesLoaded && !force) return; + try { + this.databases = await listDatabases(); + this.databasesLoaded = true; + } catch (e) { + console.error("Failed to load databases:", e); + } + } + + async loadConversations(force = false) { + if (this.conversationsLoaded && !force) return; + try { + const res = await getConversations(); + this.conversations = res?.conversations || []; + this.conversationsLoaded = true; + } catch (e) { + console.error("Failed to load conversations:", e); + } + } + + /** Drop cached per-workspace data when the active workspace changes. */ + resetWorkspaceData() { + this.databases = []; + this.databasesLoaded = false; + this.conversations = []; + this.conversationsLoaded = false; + } + + private announceNewInvites() { + const ids = new Set((this.invites || []).map((i: any) => i.id)); + if (this.seenInviteIds === null) { + this.seenInviteIds = ids; + return; + } + const fresh = (this.invites || []).filter( + (i: any) => !this.seenInviteIds!.has(i.id), + ); + this.seenInviteIds = ids; + if (fresh.length === 0) return; + this.showToast({ + title: + fresh.length === 1 + ? "New workspace invitation" + : `${fresh.length} new workspace invitations`, + body: + fresh.length === 1 + ? `You've been invited to ${fresh[0].workspace_name}. Open the bell to accept.` + : "Open the bell in the header to accept them.", + }); + } + async init(redirect: boolean = true) { + await this.loadWorkspaces(); + if (this.activeWorkspace) { + this.loadDatabases(); + this.loadConversations(); + } try { const s = await apiCall("/api/state"); this.openrouterReady = s.openrouter_ready ?? false; @@ -75,16 +173,18 @@ class AppState { console.error("Failed to load schema:", e); } this.isLoaded = true; - if (redirect) { - if (this.dbInfo?.has_knowledge) { - goto("/chat"); - } else { - goto("/onboard"); - } + if (this.workspaces.length === 0) { + goto("/workspaces/setup"); + } else if (redirect) { + goto("/chat"); } } else { this.isLoaded = true; - if (redirect) goto("/connect"); + if (this.workspaces.length === 0) { + goto("/workspaces/setup"); + } else if (redirect) { + goto("/connect"); + } } } catch (e: any) { this.isLoaded = true; @@ -121,10 +221,7 @@ class AppState { posthog.reset(); } try { - this.setOnboardingActive(false); - // Navigate to the landing page BEFORE clearing dbInfo. Clearing it while - // still on /chat (or /dashboard/onboard) would trip those pages' own - // "no database → /connect" redirect effect and race it to /connect. + // Navigate to the landing page BEFORE clearing dbInfo. await goto("/"); } finally { this.dbInfo = null; @@ -133,30 +230,80 @@ class AppState { this.starters = []; this.tourCompleted = false; this.activeConversationId = null; + this.resetWorkspaceData(); } } async setConnect(isSample: boolean, res: DbInfo) { if (res) { + if (typeof window !== "undefined" && res.db_id) { + if (this.activeWorkspace) + localStorage.setItem( + `bolodb_active_db_id_${this.activeWorkspace.id}`, + res.db_id, + ); + } this.dbInfo = res; this.verifiedCount = res.trust?.verified || 0; if (res.starters) this.starters = res.starters; + // The database this just connected has to appear in the switcher and on + // the connect screen straight away. + this.loadDatabases(true); + if (res.save_error) + this.showError(res.save_error, "Connection not saved"); try { const schema = await apiCall("/api/schema"); this.realSchema = schemaObjToDisplay(schema); } catch (e) { console.error("Failed to load schema:", e); } - // Sample databases always walk through onboarding (they ship with - // seeded knowledge, so has_knowledge can't mean "already onboarded"). - // Own databases with existing knowledge (reconnects) skip straight in. - if (!isSample && res.has_knowledge) { - goto("/chat"); - return; + // Always go to chat + goto("/chat"); + } + } + + /** + * Reconnect the workspace to `dbId`. + * + * Switching database normally abandons the open conversation, since its + * answers came from the database being left behind. Opening a conversation + * that belongs to another database is the exception — that switch exists to + * serve the conversation, so it passes `keepConversation`. + */ + async switchDatabase(dbId: string, { keepConversation = false } = {}) { + if (typeof window !== "undefined") { + if (this.activeWorkspace) + localStorage.setItem( + `bolodb_active_db_id_${this.activeWorkspace.id}`, + dbId, + ); + } + this.switchingDatabase = true; + try { + await apiCall("/api/reconnect", { db_id: dbId }); + const s = await apiCall("/api/state"); + if (s.database) { + this.dbInfo = s.database; + } + if (s.starters) { + this.starters = s.starters; } + if (!keepConversation) this.activeConversationId = null; + this.realSchema = null; + this.fetchSchemaAsync(true); + this.loadDatabases(true); + return true; + } catch (e: any) { + console.error("Failed to switch DB:", e); + // The backend explains *why* (unreachable, undecryptable credentials, no + // longer present); a generic message would hide all of that. + this.showError( + e?.message || "Failed to switch database. It may have been deleted.", + ); + return false; + } finally { + this.switchingDatabase = false; } - this.setOnboardingActive(true); - goto("/onboard"); } async setOnboardDone(seedCount: number) { @@ -176,7 +323,6 @@ class AppState { dialect: this.dbInfo?.dialect, }); } - this.setOnboardingActive(false); goto("/chat"); } @@ -224,17 +370,17 @@ class AppState { } async disconnect() { - try { - await apiCall("/api/disconnect", {}); - } catch (e) { - console.error("Failed to disconnect:", e); + if (typeof window !== "undefined") { + if (this.activeWorkspace) + localStorage.removeItem( + `bolodb_active_db_id_${this.activeWorkspace.id}`, + ); } this.dbInfo = null; this.realSchema = null; this.verifiedCount = 0; this.starters = []; this.activeConversationId = null; - this.setOnboardingActive(false); goto("/connect"); } @@ -243,6 +389,16 @@ class AppState { goto(`/chat?id=${id}`); } + async fetchSchemaAsync(forceRefresh = false) { + try { + const qs = forceRefresh ? "?refresh=true" : ""; + const schema = await apiCall(`/api/schema${qs}`); + this.realSchema = schemaObjToDisplay(schema); + } catch (e) { + console.error("Failed to load async schema:", e); + } + } + async fetchStartersAsync() { if (this.starters && this.starters.length > 0) return; try { diff --git a/frontend/src/lib/components/ActivityLog.svelte b/frontend/src/lib/components/ActivityLog.svelte new file mode 100644 index 00000000..0931f627 --- /dev/null +++ b/frontend/src/lib/components/ActivityLog.svelte @@ -0,0 +1,338 @@ + + +
+
+
+

Activity log

+

Workspace events from the last 30 days.

+
+ +
+ + {#if error} +
{error}
+ {/if} + + {#if loading && page === 1} +
+
+ Loading activity… +
+ {:else if activities.length === 0} +
+ No activity recorded yet. +
+ {:else} +
+ + + + + + + + + + + {#each activities as a (a.id)} + + + + + + + {/each} + +
TimeActorEventDetails
+ {absoluteTime(a.created_at)} + + {a.actor_email || 'System'} + + {a.event_type} + {describe(a)}
+
+ + {#if hasMore} + + {/if} + {/if} +
+ + diff --git a/frontend/src/lib/components/AnswerCard.svelte b/frontend/src/lib/components/AnswerCard.svelte index 645c0b12..f26ad17b 100644 --- a/frontend/src/lib/components/AnswerCard.svelte +++ b/frontend/src/lib/components/AnswerCard.svelte @@ -9,9 +9,10 @@ import ResultChart from '$lib/components/charts/ResultChart.svelte'; import Thinking from '$lib/components/Thinking.svelte'; import Flywheel from '$lib/components/Flywheel.svelte'; - import { detectChartData } from '$lib/components/charts/chartUtils'; + import SaveQueryDialog from '$lib/components/SaveQueryDialog.svelte'; + import { detectChartData, planChart } from '$lib/components/charts/chartUtils'; - let { turn, onVerify, isLatest, liveArtifacts, onRegenerate, onEditPrompt }: + let { turn, onVerify, isLatest, liveArtifacts, onRegenerate, onEditPrompt, onRerun, rerunning = false }: { turn: Turn; onVerify: (id: string, verdict: string, reason: string | null) => void; @@ -19,6 +20,9 @@ liveArtifacts?: ThinkingArtifact[]; onRegenerate?: (id: string) => void; onEditPrompt?: (id: string, newQuestion: string) => void; + /** Re-execute this turn's SQL — no model call, so no tokens spent. */ + onRerun?: (id: string) => void; + rerunning?: boolean; } = $props(); let showReasons = $state(false); @@ -27,11 +31,35 @@ let editing = $state(false); let editValue = $state(''); let copyFeedback = $state<'response' | 'prompt' | null>(null); + let showSaveDialog = $state(false); + const stringRows = $derived((turn.rows || []).map(r => r.map(String))); + + // The model picks the chart from the SQL it wrote, so trust it when it made a + // call; the local heuristic only covers turns that have no chart spec. + const modelPlan = $derived(planChart(turn.chart, turn.columns || [], stringRows)); const hasChartData = $derived( - detectChartData(turn.columns || [], (turn.rows || []).map(r => r.map(String))) !== null + modelPlan !== null || + detectChartData(turn.columns || [], stringRows) !== null + ); + // "table" is a deliberate choice by the model, not an absent one — respect it. + const modelWantsChart = $derived(!!turn.chart && turn.chart.type !== 'table' && modelPlan !== null); + + // Open on the chart when the model asked for one, without pinning the toggle. + let userPickedView = $state(false); + const effectiveView = $derived( + userPickedView ? viewMode : (modelWantsChart ? 'chart' : 'table') ); + function toggleView() { + // Capture the view actually on screen *before* flipping the flag — once + // userPickedView is true, effectiveView resolves to viewMode, so reading it + // afterwards would ignore the model's chart default and no-op the first click. + const current = effectiveView; + userPickedView = true; + viewMode = current === 'table' ? 'chart' : 'table'; + } + function yes() { justVerified = true; onVerify(turn.id, 'correct', null); setTimeout(() => justVerified = false, 1600); } function no(reason: string) { showReasons = false; onVerify(turn.id, 'wrong', reason); } @@ -103,6 +131,26 @@ } +{#snippet rerunButton()} + {#if onRerun && turn.sql} + {#if turn.lastRunAt} + Updated {formatTime(turn.lastRunAt)} + {/if} + + {/if} +{/snippet} +
@@ -169,6 +217,9 @@
{turn.executionError}
{:else} +
+ {@render rerunButton()} +
{/if} {#if turn.resultTruncated} @@ -219,13 +270,14 @@ {/if} {#if !turn.executionError && turn.sql} -
+
+ {@render rerunButton()} {#if hasChartData} - viewMode = viewMode === 'table' ? 'chart' : 'table'} /> + {/if}
- {#if viewMode === 'chart' && hasChartData} - r.map(String))} /> + {#if effectiveView === 'chart' && hasChartData} + {:else} {/if} @@ -295,6 +347,11 @@ + {#if turn.sql} + + {/if}
+{#if showSaveDialog} + showSaveDialog = false} /> +{/if} + diff --git a/frontend/src/lib/components/AskScreen.svelte b/frontend/src/lib/components/AskScreen.svelte index c60918e6..49da044a 100644 --- a/frontend/src/lib/components/AskScreen.svelte +++ b/frontend/src/lib/components/AskScreen.svelte @@ -5,17 +5,15 @@ Turn, SchemaTable, DbInfo, - Toast, ThinkingArtifact, StreamEvent, Conversation, ConversationTurn, } from "$lib/types"; - import Sidebar from "$lib/components/Sidebar.svelte"; + import { page } from "$app/state"; + import AppShell from "$lib/components/AppShell.svelte"; import AnswerCard from "$lib/components/AnswerCard.svelte"; - import DashboardTab from "$lib/components/DashboardTab.svelte"; import SettingsTab from "$lib/components/SettingsTab.svelte"; - import TrustToast from "$lib/components/TrustToast.svelte"; import Spinner from '$lib/components/ui/Spinner.svelte'; import LoadingScreen from '$lib/components/ui/LoadingScreen.svelte'; import SlashCommandMenu from "$lib/components/ui/SlashCommandMenu.svelte"; @@ -28,7 +26,6 @@ verifiedCount, onVerify, onUpdateStarters, - toast, realSchema, dbInfo, starters, @@ -38,7 +35,6 @@ verifiedCount: number; onVerify: (count?: number) => void; onUpdateStarters: (s: string[]) => void; - toast: Toast | null; realSchema: SchemaTable[] | null; dbInfo: DbInfo | null; starters: string[]; @@ -46,24 +42,22 @@ onActiveConversationChange?: (id: string | null) => void; } = $props(); - const dbLabel = $derived( - dbInfo - ? (dbInfo.url || "").split("/").pop() || dbInfo.dialect || "your database" - : "your database", - ); - const tableCount = $derived(dbInfo ? dbInfo.tables || 0 : 0); let turns: Turn[] = $state([]); let input = $state(""); - let tab = $state<"ask" | "dash" | "settings">("ask"); - let userEmail = $state(""); + // ?tab=settings lets the sidebar land directly on Settings when coming back + // from a route that isn't /chat. + let tab = $state<"ask" | "settings">( + page.url.searchParams.get("tab") === "settings" ? "settings" : "ask", + ); let openCatalogTrigger = $state(0); - onMount(async () => { + onMount(() => { appState.fetchStartersAsync(); - try { - const res = await apiCall("/api/auth/me"); - userEmail = res?.content?.email || ""; - } catch {} + // Arriving from another route (the sidebar on /dashboards, say) carries the + // conversation to open in the URL, since that screen has no feed to load it + // into itself. + const requested = page.url.searchParams.get('conversation'); + if (requested) handleConversationSelect(requested); }); const suggestionChips = $derived( @@ -77,11 +71,38 @@ let conversationTrigger = $state(0); let activeConversationId: string | null = $state(null); let abortController: AbortController | null = $state(null); + /** Turn ids whose SQL is being re-executed right now. */ + let rerunning = $state>(new Set()); let showScrollBtn = $state(false); let lastTurnCount = 0; let convLoadSeq = 0; let convLoading = $state(false); // true only while fetching a past conversation - let mobileNavOpen = $state(false); + + // The database can be switched from the shared shell on any screen, so the + // feed reacts to the active database changing rather than to the click. + let lastDbId: string | null = null; + $effect(() => { + const id = dbInfo?.db_id ?? null; + if (lastDbId !== null && id !== lastDbId) { + abortController?.abort(); + turns = []; + activeConversationId = null; + onActiveConversationChange(null); + } + lastDbId = id; + }); + + /** + * Adopt a database change as already-seen, so the effect above treats it as + * the current state rather than as a switch to clear the feed over. Opening a + * conversation recorded against another database switches to it deliberately; + * without this, the effect fired straight afterwards and wiped the turns that + * had just been restored — which is why past conversations opened blank. + */ + function acknowledgeDbId(id: string | null) { + lastDbId = id; + } + const trust = $derived(trustFor(verifiedCount)); @@ -323,6 +344,7 @@ rows: rowsToArrays(data.columns || [], data.rows || []), confidence: data.confidence || "medium", reason: data.confidence_reason || "", + chart: data.chart || null, basedOn: data.based_on_verified || false, query_id: data.query_id || id, executionError: data.execution_error || null, @@ -426,6 +448,43 @@ requeryAt(idx, turns[idx].question); } + /** + * Re-run the SQL a turn already has, without going back to the model. Same + * question, fresh rows — the point is to refresh a result for free rather + * than spend tokens regenerating identical SQL. + */ + async function rerunSql(turnId: string) { + const turn = turns.find((t) => t.id === turnId); + if (!turn?.sql || rerunning.has(turnId)) return; + rerunning = new Set([...rerunning, turnId]); + try { + const data = await apiCall("/api/execute", { + sql: turn.sql, + save_history: false, + }); + turns = turns.map((t) => + t.id === turnId + ? { + ...t, + columns: data.columns || [], + rows: rowsToArrays(data.columns || [], data.rows || []), + executionError: null, + resultTruncated: false, + lastRunAt: new Date().toISOString(), + } + : t, + ); + } catch (e: any) { + appState.showError( + e?.message || "Couldn't re-run this query against your database.", + ); + } finally { + const next = new Set(rerunning); + next.delete(turnId); + rerunning = next; + } + } + function editAndRequery(turnId: string, newQuestion: string) { const idx = turns.findIndex((t) => t.id === turnId); if (idx === -1) return; @@ -503,6 +562,7 @@ })), confidence: (t.confidence || 'medium').toLowerCase() as "high" | "medium" | "low", reason: '', + chart: t.chart || null, basedOn: false, query_id: t._id, executionError: null, @@ -513,9 +573,6 @@ async function handleConversationSelect(convId: string) { if (convId === activeConversationId) return; - // Cancel any in-flight query stream and stamp this load so that when two - // conversations are clicked in quick succession, only the latest click's - // response is applied (otherwise the slower fetch would win). abortController?.abort(); const seq = ++convLoadSeq; loading = true; @@ -524,6 +581,17 @@ try { const conv = await getConversation(convId); if (seq !== convLoadSeq) return; + + // Auto-switch database if the conversation belongs to a different one + if (conv.database_id && dbInfo?.db_id !== conv.database_id) { + const switched = await appState.switchDatabase(conv.database_id, { + keepConversation: true, + }); + if (seq !== convLoadSeq) return; + if (!switched) return; // switchDatabase already explained why + acknowledgeDbId(conv.database_id); + } + activeConversationId = conv._id; onActiveConversationChange(conv._id); const loaded: Turn[] = []; @@ -549,73 +617,19 @@ e.preventDefault(); ask(); } - - // Close mobile nav when viewport leaves mobile breakpoint - onMount(() => { - if (typeof window !== 'undefined') { - const mediaQuery = window.matchMedia('(max-width: 768px)'); - const handleMediaChange = (e: MediaQueryListEvent | MediaQueryList) => { - if (!e.matches) { - mobileNavOpen = false; - } - }; - // Initial check - handleMediaChange(mediaQuery); - // Listen for changes - mediaQuery.addEventListener('change', handleMediaChange); - return () => mediaQuery.removeEventListener('change', handleMediaChange); - } - }); - -
- (tab = t)} - {verifiedCount} - schema={realSchema} - {dbInfo} - {conversationTrigger} - {activeConversationId} - onConversationSelect={handleConversationSelect} - onNewChat={handleNewConversation} - {userEmail} - theme={appState.theme} - onToggleTheme={() => appState.toggleTheme()} - onLogout={() => appState.logout()} - mobileOpen={mobileNavOpen} - onClose={() => (mobileNavOpen = false)} - /> - - {#if mobileNavOpen} - - {/if} - -
- -
- - BoloDB -
+ (tab = t as "ask" | "settings")} + {dbInfo} + {verifiedCount} + {realSchema} + {conversationTrigger} + {activeConversationId} + onConversationSelect={handleConversationSelect} + onNewChat={handleNewConversation} +> {#if tab === "ask"} - -
-
- 🗄 -
- {dbLabel} - {tableCount > 0 ? `${tableCount} table${tableCount === 1 ? "" : "s"} · ` : ""}read-only -
-
- -
-
{#if convLoading} @@ -624,6 +638,12 @@ submessage="Fetching your previous results" variant="default" /> + {:else if appState.switchingDatabase} + {:else}
{#if turns.length === 0} @@ -658,6 +678,8 @@ liveArtifacts={t.thinking ? currentArtifacts : undefined} onRegenerate={regenerate} onEditPrompt={editAndRequery} + onRerun={rerunSql} + rerunning={rerunning.has(t.id)} /> {/each} {/if} @@ -702,9 +724,6 @@
- {#if toast}{/if} - {:else if tab === "dash"} - {:else} {/if} -
-
+ diff --git a/frontend/src/lib/components/ConnectScreen.svelte b/frontend/src/lib/components/ConnectScreen.svelte index 10cda9ab..a4c605d7 100644 --- a/frontend/src/lib/components/ConnectScreen.svelte +++ b/frontend/src/lib/components/ConnectScreen.svelte @@ -1,10 +1,9 @@ -
+
{#if connecting} {/if} - - -
-

Let's meet your data.

-

- One click to explore, or connect your own database. Everything runs - read-only — nothing can be changed. -

-
- - +
+ - - {#if choice === "own"} - { if (e.key === "Enter") start(); }} - placeholder="postgresql://readonly_user:pass@host:5432/dbname" - data-testid="connect-url-input" - aria-label="Database connection string" - /> - {/if} - - {#if error} - - {/if} - - {#if choice === "own" && !appState.openrouterReady} -
AI not yet ready — set OPENROUTER_API_KEY in the server environment.
- {/if} - - +

+
- {#if recentConnections.length > 0} -
-
Recent databases · reconnect in one click
-
+
+ +
+

Workspace Databases

+ {#if loadingConnections} +
+
+
+
+ + + + +
+
+
+
+
+
+
+
+
+

Loading workspace databases…

+
+ {:else if recentConnections.length > 0} +
{#each recentConnections as conn} -
- - - -
- {conn.display_url?.split("/").pop() || conn.dialect || "Database"} - {DIALECT_LABELS[conn.dialect] || conn.dialect} · {conn.table_count || 0} table{conn.table_count === 1 ? "" : "s"}{conn.connected_at ? ` · ${timeAgo(conn.connected_at)}` : ""} +
+
+ {#if editingAliasId === conn.id} + handleRenameAlias(conn.id)} + onkeydown={(e) => { if (e.key === 'Enter') handleRenameAlias(conn.id); if (e.key === 'Escape') editingAliasId = null; }} + class="alias-edit" + autofocus + /> + {:else} + + {conn.alias_name || conn.display_url?.split('@').pop()?.split('/')[0] || conn.dialect} + + {#if isAdmin} + + {/if} + {/if} +
+
+ {DIALECT_LABELS[conn.dialect] || conn.dialect} + {conn.table_count} tables +
+ - -
{/each}
+ {:else} +
+
+ + + + +
+

No databases connected

+ {#if isAdmin} +

Use the form on the right to add your first database connection.

+ {:else} +

Your workspace administrators have not connected any databases yet.
Please contact them to add a data source.

+ {/if} +
+ {/if} +
+ + + {#if isAdmin} +
+

Add New Connection

+ +
+ + +
+ + {#if choice === "own"} +
+ { if (e.key === "Enter") start(); }} + placeholder="postgresql://readonly_user:pass@host:5432/dbname" + data-testid="db-url-input" + /> + { if (e.key === "Enter") start(); }} + placeholder="Alias (e.g. Production DB) [Optional]" + /> +
+ {:else} +
+ 🛍️ +
+ Sample Webshop
+ 10 tables • 1,000 customers • 2,000 orders • Instant setup +
+
+ {/if} + + {#if error} +
{error}
+ {/if} + + + +

+ + We only run SELECT queries. Your data is never modified. +

{/if}
- -
diff --git a/frontend/src/lib/components/DashboardEditor.svelte b/frontend/src/lib/components/DashboardEditor.svelte new file mode 100644 index 00000000..7d58138b --- /dev/null +++ b/frontend/src/lib/components/DashboardEditor.svelte @@ -0,0 +1,380 @@ + + +
+ + + Drag panels to rearrange. Layout saves automatically on drop. +
+ +{#if !dashboard.panels?.length} +
+

No panels yet

+

Add a panel from a saved query to start building this dashboard.

+ +
+{:else} +
+ {#each dashboard.panels as panel (panelKey(panel))} + {@const w = panel.position?.w || 4} + {@const h = panel.position?.h || 4} + {@const sqId = String(panel.saved_query_id || '')} +
handleDragStart(e, panel)} + ondragover={handleDragOver} + ondrop={(e) => handleDrop(e, panel)} + ondragend={handleDragEnd} + > + + +
+ +
+
+ {/each} +
+{/if} + +{#if showAddModal} + + + +{/if} + + diff --git a/frontend/src/lib/components/DashboardTab.svelte b/frontend/src/lib/components/DashboardTab.svelte deleted file mode 100644 index da5fbc47..00000000 --- a/frontend/src/lib/components/DashboardTab.svelte +++ /dev/null @@ -1,191 +0,0 @@ - - -
-
- - -
-
-

Dashboard

-

Analytics, trust metrics, and schema overview.

-
-
- - {#if dbInfo} - - -
- - -
-

Connection

-
-
- Database - {dbInfo.dialect} -
-
- Status - - - Connected - -
-
-
- - -
-

Total Queries

- {#if statsLoading} -
- {:else} -
{stats?.total_queries ?? 0}
-
queries executed
- {/if} -
- - -
-

Trust Level

- -
-
- - {#if statsError} -
- - Could not load query statistics — the stats API may be unavailable. Charts will update once the connection is restored. -
- {/if} - - -
-
- - {#if stats} - - {/if} - -
- -
- - {#if stats} - - {/if} - -
-
- - -
-
- - {#if stats} - - {/if} - -
- -
- - {#if appState.realSchema} - - {/if} - -
-
- - - {#if appState.realSchema && appState.realSchema.length > 0} -
-

Indexed Schema Details

-
- {#each appState.realSchema as table} -
{ e.currentTarget.style.boxShadow = 'var(--shadow)'; e.currentTarget.style.borderColor = 'var(--brand-tint-2)'; }} onmouseleave={(e) => { e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.borderColor = 'var(--border)'; }}> -
-

{table.name}

- {table.rows} rows -
-
- {#each table.cols as col} - - {col} - - {/each} -
-
- {/each} -
-
- {/if} - - {:else} -
-
-
- -
-

No database connected

-

Connect a database to view your dashboard analytics.

- -
-
- {/if} -
-
- - diff --git a/frontend/src/lib/components/DataCatalog.svelte b/frontend/src/lib/components/DataCatalog.svelte index e84bb81d..21fe74c5 100644 --- a/frontend/src/lib/components/DataCatalog.svelte +++ b/frontend/src/lib/components/DataCatalog.svelte @@ -3,9 +3,15 @@ import { getCatalog, saveCatalog, suggestCatalog } from "$lib/api"; import Button from "$lib/components/ui/Button.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte"; + import { appState } from "$lib/appState.svelte"; let { onClose }: { onClose: () => void } = $props(); + const isAdmin = $derived( + appState.activeWorkspace?.role === "admin" || + appState.activeWorkspace?.role === "owner" + ); + type Field = { k: string; label: string; @@ -227,6 +233,7 @@ Teach BoloDB your business language. It's sent with every question to answer more accurately.
+ {#if isAdmin} + {/if}
@@ -269,6 +277,7 @@ {/if} {/each} + {#if isAdmin} + {/if}
{/each} + {#if isAdmin} + {/if}
{/each} {/if} @@ -335,6 +349,7 @@ >{totalEntries} {totalEntries === 1 ? "entry" : "entries"}
+ {#if isAdmin} + {:else} + + {/if}
diff --git a/frontend/src/lib/components/ExitIntentModal.svelte b/frontend/src/lib/components/ExitIntentModal.svelte index d3af6549..7a6ea000 100644 --- a/frontend/src/lib/components/ExitIntentModal.svelte +++ b/frontend/src/lib/components/ExitIntentModal.svelte @@ -142,7 +142,7 @@

No credit card, no database of your own needed. We spin up a realistic - TechStore e-commerce dataset so you can see BoloDB answer real questions + webshop dataset so you can see BoloDB answer real questions in under 30 seconds.

diff --git a/frontend/src/lib/components/OnboardScreen.svelte b/frontend/src/lib/components/OnboardScreen.svelte deleted file mode 100644 index 666045b5..00000000 --- a/frontend/src/lib/components/OnboardScreen.svelte +++ /dev/null @@ -1,117 +0,0 @@ - - -
- {#if onChangeDb} - - {/if} - - -
- {#if saving} - - {:else} - - {/if} -
- - -
- - diff --git a/frontend/src/lib/components/SaveQueryDialog.svelte b/frontend/src/lib/components/SaveQueryDialog.svelte new file mode 100644 index 00000000..55223484 --- /dev/null +++ b/frontend/src/lib/components/SaveQueryDialog.svelte @@ -0,0 +1,420 @@ + + + + + diff --git a/frontend/src/lib/components/SettingsTab.svelte b/frontend/src/lib/components/SettingsTab.svelte index a5124182..9b8c6820 100644 --- a/frontend/src/lib/components/SettingsTab.svelte +++ b/frontend/src/lib/components/SettingsTab.svelte @@ -1,6 +1,9 @@
-
-

Settings

+
+
+
+

Workspace

+

App settings

+

Connection, catalog, and appearance for this session.

+
+ +
-
- CONNECTION -
- {dbLabel} - READ-ONLY ENFORCED +
+
Connection
+
+
+
+ + {dbLabel} +
+
+ {dbInfo ? `${dbInfo.dialect || 'SQL'} · read-only enforced` : 'Connect a database to start asking questions'} +
+
+
+ {#if dbInfo} + + {#if onDisconnect} + + {/if} + {#if isAdmin} + + {/if} + {:else} + + {/if} +
- {#if onDisconnect} - - {/if} -
+ -
- DATA CATALOG -

Teach BoloDB your business terms, metrics and value meanings so answers get more accurate.

- -
+
+
Data catalog
+

+ Teach BoloDB your business terms, metrics, and value meanings so answers get more accurate over time. +

+ {#if isAdmin} + + {:else} +

Only workspace admins can modify the data catalog.

+ {/if} +
-
- APPEARANCE -
- Theme +
+
Appearance
+
+
+
Theme
+
Quick toggle for this device.
+
-
+ + -
- PRIVACY +
+
Privacy

- Knowledge and settings are stored per database. Only your question, schema and a few sample - values are sent to the AI — never rows, results or credentials. + Knowledge and settings are stored per workspace. Only your question, schema, and a few sample + values are sent to the AI — never rows, results, or credentials.

-
+
@@ -73,51 +124,131 @@ {/if} diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index 8979ede1..b496b8eb 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -1,8 +1,10 @@ - +
diff --git a/frontend/src/lib/components/charts/ConfidenceDonut.svelte b/frontend/src/lib/components/charts/ConfidenceDonut.svelte deleted file mode 100644 index cac82619..00000000 --- a/frontend/src/lib/components/charts/ConfidenceDonut.svelte +++ /dev/null @@ -1,55 +0,0 @@ - - -{#if total === 0} -
- No queries yet -
-{:else} -
-
- CONFIDENCE_COLORS[d.key] ?? 'var(--faint)')} - innerRadius={0.55} - cornerRadius={4} - padAngle={0.02} - labels={{ placement: 'centroid', format: (v: any) => { const n = Number(v); const pct = Math.round((n / total) * 100); return pct >= 5 ? `${pct}%` : ''; } }} - /> -
-
- {#each chartData as item} - {@const pct = Math.round((item.value / total) * 100)} -
-
- {item.label} - {item.value} - {pct}% -
- {/each} -
- Total: {total} queries -
-
-
-{/if} diff --git a/frontend/src/lib/components/charts/QueryTimeline.svelte b/frontend/src/lib/components/charts/QueryTimeline.svelte deleted file mode 100644 index 11eed8b9..00000000 --- a/frontend/src/lib/components/charts/QueryTimeline.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - -{#if data.length === 0} -
- No activity yet -
-{:else} -
- -
-{/if} diff --git a/frontend/src/lib/components/charts/ResultChart.svelte b/frontend/src/lib/components/charts/ResultChart.svelte index a612b735..56fbbc4f 100644 --- a/frontend/src/lib/components/charts/ResultChart.svelte +++ b/frontend/src/lib/components/charts/ResultChart.svelte @@ -1,90 +1,295 @@ + // planChart returns null when the spec is unusable against these columns, so + // the heuristic still covers old turns and non-chartable results. + const plan = $derived( + planChart(spec, columns, rows) ?? + (() => { + const d = detectChartData(columns, rows); + return d && d.type + ? { type: d.type, labelKey: d.labelKey, valueKey: d.valueKey, data: d.data, title: '' } + : null; + })(), + ); + + /** Bars need room to breathe; taller as the category count grows, then capped. */ + const chartHeight = $derived( + plan?.type === 'bar' + ? Math.max(160, Math.min(plan.data.length * 30 + 40, 420)) + : 240, + ); + + let chartDom: HTMLElement | undefined = $state(); + + $effect(() => { + if (!chartDom || !plan || plan.type === 'number') return; + const instance = echarts.init(chartDom); + instance.setOption(buildOption(plan, appState.theme), true); -{#if !detection || !detection.type} -
- This data doesn't have a chartable format. -
-{:else if detection.type === 'bar'} -
- -
-{:else if detection.type === 'pie'} -
-
- { - const n = Number(v); - const total = detection.data.reduce((s: number, item: { value: number }) => s + item.value, 0); - const pct = total > 0 ? Math.round((n / total) * 100) : 0; - return pct >= 5 ? `${pct}%` : ''; + const observer = new ResizeObserver(() => instance.resize()); + observer.observe(chartDom); + return () => { + observer.disconnect(); + instance.dispose(); + }; + }); + + function buildOption(p: NonNullable, theme: string) { + const palette = chartColors(theme); + const ink = cssVar('--ink', theme === 'dark' ? '#f0f0f0' : '#16201b'); + const muted = cssVar('--muted', theme === 'dark' ? '#8b8d91' : '#5c6b63'); + const surface = cssVar('--surface', theme === 'dark' ? '#141518' : '#ffffff'); + const border = cssVar('--border', theme === 'dark' ? '#2c2e33' : '#e3e8e5'); + const labels = p.data.map((d) => d.label); + const values = p.data.map((d) => d.value); + + const base: any = { + color: palette, + animationDuration: 400, + textStyle: { color: muted, fontFamily: 'inherit', fontSize: 11.5 }, + tooltip: { + backgroundColor: surface, + borderColor: border, + borderWidth: 1, + textStyle: { color: ink, fontSize: 12 }, + extraCssText: 'border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.14);', + }, + }; + + if (p.type === 'pie') { + const total = values.reduce((s, v) => s + v, 0); + return { + ...base, + tooltip: { + ...base.tooltip, + trigger: 'item', + formatter: (i: any) => + `${i.marker} ${i.name}
${formatNumber(i.value)} · ${i.percent}%`, + }, + legend: { + type: 'scroll', + orient: 'vertical', + right: 0, + top: 'middle', + itemWidth: 9, + itemHeight: 9, + itemGap: 8, + // Identity stays in text ink; the swatch beside it carries the colour. + textStyle: { color: ink, fontSize: 12 }, + }, + series: [ + { + type: 'pie', + radius: ['52%', '78%'], + center: ['32%', '50%'], + // A 2px ring of surface between slices keeps neighbours legible + // without a border colour of their own. + itemStyle: { borderColor: surface, borderWidth: 2, borderRadius: 3 }, + // Only slices with room get a direct label — never one per slice. + label: { + show: true, + color: ink, + fontSize: 11, + formatter: (i: any) => (i.percent >= 8 ? `${Math.round(i.percent)}%` : ''), + }, + labelLine: { show: false }, + data: p.data.map((d) => ({ name: d.label, value: d.value })), }, - }} - /> + ], + }; + } + + if (p.type === 'bar') { + return { + ...base, + grid: { left: 4, right: 24, top: 8, bottom: 4, containLabel: true }, + tooltip: { + ...base.tooltip, + trigger: 'axis', + axisPointer: { type: 'shadow', shadowStyle: { color: `${muted}14` } }, + formatter: (ps: any[]) => + `${ps[0].name}
${formatNumber(ps[0].value)} ${p.valueKey}`, + }, + xAxis: { + type: 'value', + axisLabel: { color: muted, formatter: (v: number) => formatNumber(v) }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { lineStyle: { color: border, type: 'dashed' } }, + }, + yAxis: { + type: 'category', + data: labels, + inverse: true, + axisLabel: { + color: muted, + width: 110, + overflow: 'truncate', + }, + axisLine: { show: false }, + axisTick: { show: false }, + }, + series: [ + { + type: 'bar', + data: values, + barMaxWidth: 18, + // Rounded at the data end only — the baseline end stays square so + // bars read as measured from zero. + itemStyle: { color: palette[0], borderRadius: [0, 4, 4, 0] }, + }, + ], + }; + } + + // line / area + const isArea = p.type === 'area'; + return { + ...base, + grid: { left: 4, right: 12, top: 12, bottom: 4, containLabel: true }, + tooltip: { + ...base.tooltip, + trigger: 'axis', + axisPointer: { type: 'line', lineStyle: { color: border } }, + formatter: (ps: any[]) => + `${ps[0].axisValue}
${formatNumber(ps[0].value)} ${p.valueKey}`, + }, + xAxis: { + type: 'category', + data: labels, + boundaryGap: false, + axisLabel: { color: muted, hideOverlap: true }, + axisLine: { lineStyle: { color: border } }, + axisTick: { show: false }, + }, + yAxis: { + type: 'value', + axisLabel: { color: muted, formatter: (v: number) => formatNumber(v) }, + axisLine: { show: false }, + splitLine: { lineStyle: { color: border, type: 'dashed' } }, + }, + series: [ + { + type: 'line', + data: values, + smooth: false, + symbol: 'circle', + symbolSize: 8, + showSymbol: p.data.length <= 40, + lineStyle: { width: 2, color: palette[0] }, + itemStyle: { color: palette[0], borderColor: surface, borderWidth: 2 }, + areaStyle: isArea + ? { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: `${palette[0]}59` }, + { offset: 1, color: `${palette[0]}05` }, + ]), + } + : undefined, + }, + ], + }; + } + + +{#if !plan} +
This data doesn't have a chartable format.
+{:else} + {#if plan.title} +
{plan.title}
+ {/if} + + {#if plan.type === 'number'} + +
+
{formatNumber(plan.data[0].value)}
+
{plan.valueKey}
-
- {#each detection.data as item} - {@const total = detection.data.reduce((s: number, d: { value: number }) => s + d.value, 0)} - {@const pct = total > 0 ? Math.round((item.value / total) * 100) : 0} -
-
- {item.label} - {item.value.toLocaleString()} - {pct}% -
- {/each} + {:else} +
+
-
-{:else if detection.type === 'line'} -
- -
+ {/if} {/if} + + diff --git a/frontend/src/lib/components/charts/SchemaOverview.svelte b/frontend/src/lib/components/charts/SchemaOverview.svelte deleted file mode 100644 index 63fe5899..00000000 --- a/frontend/src/lib/components/charts/SchemaOverview.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - -{#if tableData.length === 0} -
- No schema data -
-{:else} -
- {#each tableData as table, i} - {@const pct = Math.round((table.rowCount / maxRows) * 100)} -
-
- {table.name} - {table.rowCount.toLocaleString()} rows · {table.colCount} cols -
-
-
-
-
- {/each} -
-{/if} diff --git a/frontend/src/lib/components/charts/TableUsageBar.svelte b/frontend/src/lib/components/charts/TableUsageBar.svelte deleted file mode 100644 index 0da45ad9..00000000 --- a/frontend/src/lib/components/charts/TableUsageBar.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - -{#if chartData.length === 0} -
- No table usage data -
-{:else} -
- -
-{/if} diff --git a/frontend/src/lib/components/charts/TrustGauge.svelte b/frontend/src/lib/components/charts/TrustGauge.svelte deleted file mode 100644 index d81555f9..00000000 --- a/frontend/src/lib/components/charts/TrustGauge.svelte +++ /dev/null @@ -1,55 +0,0 @@ - - -
-
- -
- -
-
- - {level.label} -
- {#if nextMilestone !== null} -
- {nextMilestone - verifiedCount} more verification{nextMilestone - verifiedCount === 1 ? '' : 's'} to reach {level.key === 'supervised' ? 'Assisted' : 'Trusted'} -
- {:else} -
- Maximum trust level reached -
- {/if} -
-
diff --git a/frontend/src/lib/components/charts/chartUtils.ts b/frontend/src/lib/components/charts/chartUtils.ts index e7520264..fb471d6f 100644 --- a/frontend/src/lib/components/charts/chartUtils.ts +++ b/frontend/src/lib/components/charts/chartUtils.ts @@ -98,17 +98,148 @@ export function detectChartData( return best; } +/** A chart the model asked for, resolved against the columns actually returned. */ +export type ChartPlan = + | { + type: "bar" | "pie" | "line" | "area"; + labelKey: string; + valueKey: string; + data: { label: string; value: number }[]; + title: string; + } + | { + type: "number"; + labelKey: string; + valueKey: string; + data: { label: string; value: number }[]; + title: string; + }; + +/** + * Resolve the model's chart choice against real result columns. + * + * The model picks from the SQL it wrote, so its axis names normally match. When + * they don't — a renamed alias, a repaired query — we fall back to the + * positional heuristic rather than rendering nothing. + */ +export function planChart( + spec: + | { type?: string; x_axis?: string; y_axis?: string; title?: string } + | null + | undefined, + columns: string[], + rows: string[][], +): ChartPlan | null { + const type = spec?.type; + if (!type || type === "table") return null; + if (!columns?.length || !rows?.length) return null; + + const findCol = (name?: string) => { + if (!name) return -1; + const target = name.trim().toLowerCase(); + return columns.findIndex((c) => c.trim().toLowerCase() === target); + }; + + if (type === "number") { + const valIdx = findCol(spec?.y_axis); + const idx = valIdx >= 0 ? valIdx : 0; + const value = parseNumeric(String(rows[0]?.[idx] ?? "")); + if (value === null) return null; + return { + type: "number", + labelKey: "", + valueKey: columns[idx], + data: [{ label: columns[idx], value }], + title: spec?.title || "", + }; + } + + if (type !== "bar" && type !== "pie" && type !== "line" && type !== "area") { + return null; + } + + let labelIdx = findCol(spec?.x_axis); + let valueIdx = findCol(spec?.y_axis); + + if (labelIdx < 0 || valueIdx < 0 || labelIdx === valueIdx) { + const detected = detectChartData(columns, rows); + if (!detected) return null; + labelIdx = columns.indexOf(detected.labelKey); + valueIdx = columns.indexOf(detected.valueKey); + if (labelIdx < 0 || valueIdx < 0) return null; + } + + const data = rows + .map((r) => { + const value = parseNumeric(String(r?.[valueIdx] ?? "")); + return value === null + ? null + : { label: String(r?.[labelIdx] ?? ""), value }; + }) + .filter((d): d is { label: string; value: number } => d !== null); + + if (!data.length) return null; + + return { + type: type as "bar" | "pie" | "line" | "area", + labelKey: columns[labelIdx], + valueKey: columns[valueIdx], + data, + title: spec?.title || "", + }; +} + +/** + * Categorical series colours, in fixed order — slot 1 is always the first + * series, never re-assigned when a filter changes how many series there are. + * + * Light and dark are separate sets rather than one set re-used, because the + * readable lightness band differs per background: the steps below sit inside + * OKLCH L 0.43–0.77 on light and 0.48–0.67 on dark. Both orders were checked + * for colour-vision separation between neighbouring slots — the previous order + * put teal next to pink, which deuteranopes cannot tell apart (ΔE 3.7). + * Re-check with the dataviz skill's `validate_palette.js` before editing. + */ export const CHART_COLORS = [ - "var(--brand)", + "#1b9e6b", "#6366f1", "#f59e0b", + "#ec4899", + "#0ea5e9", "#ef4444", "#8b5cf6", - "#06b6d4", + "#65a30d", +]; + +export const CHART_COLORS_DARK = [ + "#1b9e6b", + "#6366f1", + "#d97706", "#ec4899", - "#14b8a6", + "#0b8ec7", + "#ef4444", + "#8b5cf6", + "#65a30d", ]; +export function chartColors(theme: string): string[] { + return theme === "dark" ? CHART_COLORS_DARK : CHART_COLORS; +} + +/** + * Resolve a CSS custom property to a concrete colour. + * + * ECharts paints to a canvas, where `var(--ink)` means nothing — every colour + * handed to it has to be resolved against the document first. + */ +export function cssVar(name: string, fallback: string): string { + if (typeof window === "undefined") return fallback; + const v = getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); + return v || fallback; +} + export const CONFIDENCE_COLORS: Record = { High: "var(--c-high)", Medium: "var(--c-med)", diff --git a/frontend/src/lib/components/ui/ConfirmDialog.svelte b/frontend/src/lib/components/ui/ConfirmDialog.svelte new file mode 100644 index 00000000..d205c5c3 --- /dev/null +++ b/frontend/src/lib/components/ui/ConfirmDialog.svelte @@ -0,0 +1,271 @@ + + +{#if open} + +{/if} + + diff --git a/frontend/src/lib/components/ui/InviteBell.svelte b/frontend/src/lib/components/ui/InviteBell.svelte new file mode 100644 index 00000000..dab21f74 --- /dev/null +++ b/frontend/src/lib/components/ui/InviteBell.svelte @@ -0,0 +1,190 @@ + + +{#if count > 0} +
+ + + {#if open} +
+
+ Pending invitation{count === 1 ? '' : 's'} +
+ {#each invites as invite (invite.id)} +
+
+
{invite.workspace_name}
+
Invited as {invite.role}
+
+ +
+ {/each} +
+ {/if} +
+{/if} + + diff --git a/frontend/src/lib/components/ui/Navbar.svelte b/frontend/src/lib/components/ui/Navbar.svelte index 3fbda51d..b001b3d8 100644 --- a/frontend/src/lib/components/ui/Navbar.svelte +++ b/frontend/src/lib/components/ui/Navbar.svelte @@ -4,6 +4,7 @@ import { onMount } from 'svelte'; import { apiCall } from '$lib/api'; import Logo from './Logo.svelte'; + import InviteBell from './InviteBell.svelte'; let userEmail = $state(''); let menuOpen = $state(false); @@ -47,6 +48,7 @@