From f6bcd1ca5f04c04cb314bff9d70a0480b70920fc Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 9 Feb 2026 00:51:26 +0530 Subject: [PATCH] refactor: update migration paths and improve database operation handling --- AGENTS.md | 2 +- Makefile | 2 +- backend/.flake8 | 4 +- backend/Dockerfile | 2 +- backend/README.md | 2 +- backend/alembic.ini | 2 +- .../050c16fde00e_backfill_invite_access.py | 89 --- .../versions/12772fdcdfe9_board_groups.py | 54 -- backend/alembic/versions/1d844b04ee06_init.py | 388 ---------- .../1f2a3b4c5d6e_add_organizations.py | 259 ------- .../23c771c93430_board_group_memory.py | 122 ---- .../versions/2c7b1c4d9e10_merge_heads.py | 24 - .../3c6a2d3df4a1_task_dependencies.py | 80 --- ...2491090_ensure_board_group_memory_table.py | 67 -- ...6e1c9b2f7a4d_add_active_organization_id.py | 70 -- .../9f0c4fb2a7b8_remove_skyll_enabled.py | 34 - ...af403671a8c4_repair_board_groups_schema.py | 79 -- backend/app/api/agents.py | 16 +- backend/app/api/board_groups.py | 19 +- backend/app/api/boards.py | 41 +- backend/app/api/gateways.py | 72 +- backend/app/api/organizations.py | 170 +++-- backend/app/api/queryset.py | 56 ++ backend/app/api/tasks.py | 12 +- backend/app/db/crud.py | 81 ++- backend/app/db/queryset.py | 43 ++ backend/app/db/session.py | 10 +- backend/app/db/sqlmodel_exec.py | 9 + backend/app/queries/__init__.py | 1 + backend/app/queries/organizations.py | 50 ++ backend/app/services/organizations.py | 44 +- backend/app/services/task_dependencies.py | 6 +- backend/{alembic => migrations}/env.py | 0 .../{alembic => migrations}/script.py.mako | 0 .../migrations/versions/658dca8f4a11_init.py | 678 ++++++++++++++++++ backend/pyproject.toml | 6 +- backend/tests/test_board_groups_delete.py | 3 + backend/tests/test_boards_delete.py | 6 +- .../tests/test_organizations_delete_api.py | 3 + .../test_organizations_member_remove_api.py | 4 + backend/tests/test_organizations_service.py | 4 + backend/tests/test_task_dependencies.py | 4 + docs/architecture/README.md | 2 +- 43 files changed, 1175 insertions(+), 1445 deletions(-) delete mode 100644 backend/alembic/versions/050c16fde00e_backfill_invite_access.py delete mode 100644 backend/alembic/versions/12772fdcdfe9_board_groups.py delete mode 100644 backend/alembic/versions/1d844b04ee06_init.py delete mode 100644 backend/alembic/versions/1f2a3b4c5d6e_add_organizations.py delete mode 100644 backend/alembic/versions/23c771c93430_board_group_memory.py delete mode 100644 backend/alembic/versions/2c7b1c4d9e10_merge_heads.py delete mode 100644 backend/alembic/versions/3c6a2d3df4a1_task_dependencies.py delete mode 100644 backend/alembic/versions/5fb3b2491090_ensure_board_group_memory_table.py delete mode 100644 backend/alembic/versions/6e1c9b2f7a4d_add_active_organization_id.py delete mode 100644 backend/alembic/versions/9f0c4fb2a7b8_remove_skyll_enabled.py delete mode 100644 backend/alembic/versions/af403671a8c4_repair_board_groups_schema.py create mode 100644 backend/app/api/queryset.py create mode 100644 backend/app/db/queryset.py create mode 100644 backend/app/db/sqlmodel_exec.py create mode 100644 backend/app/queries/__init__.py create mode 100644 backend/app/queries/organizations.py rename backend/{alembic => migrations}/env.py (100%) rename backend/{alembic => migrations}/script.py.mako (100%) create mode 100644 backend/migrations/versions/658dca8f4a11_init.py diff --git a/AGENTS.md b/AGENTS.md index 7b1aa47b..756e5648 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Project Structure & Module Organization - `backend/`: FastAPI service. - App code: `backend/app/` (routes `backend/app/api/`, models `backend/app/models/`, schemas `backend/app/schemas/`, workers `backend/app/workers/`). - - DB migrations: `backend/alembic/` (generated versions in `backend/alembic/versions/`). + - DB migrations: `backend/migrations/` (generated versions in `backend/migrations/versions/`). - Tests: `backend/tests/`. - `frontend/`: Next.js app. - Routes: `frontend/src/app/`; shared UI: `frontend/src/components/`; utilities: `frontend/src/lib/`. diff --git a/Makefile b/Makefile index 6441b6d6..694edd15 100644 --- a/Makefile +++ b/Makefile @@ -101,7 +101,7 @@ frontend-test: frontend-tooling ## Frontend tests (vitest) $(NODE_WRAP) --cwd $(FRONTEND_DIR) npm run test .PHONY: backend-migrate -backend-migrate: ## Apply backend DB migrations (alembic upgrade head) +backend-migrate: ## Apply backend DB migrations (uses backend/migrations) cd $(BACKEND_DIR) && uv run alembic upgrade head .PHONY: build diff --git a/backend/.flake8 b/backend/.flake8 index e2203bb5..fd1d3443 100644 --- a/backend/.flake8 +++ b/backend/.flake8 @@ -4,7 +4,7 @@ extend-ignore = E203, W503, E501 exclude = .venv, backend/.venv, - alembic, - backend/alembic, + migrations, + backend/migrations, **/__pycache__, **/*.pyc diff --git a/backend/Dockerfile b/backend/Dockerfile index 9fc59a1c..2d88e81a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -34,7 +34,7 @@ COPY --from=deps /app/.venv /app/.venv ENV PATH="/app/.venv/bin:${PATH}" # Copy app source -COPY backend/alembic ./alembic +COPY backend/migrations ./migrations COPY backend/alembic.ini ./alembic.ini COPY backend/app ./app diff --git a/backend/README.md b/backend/README.md index 553059f8..555b1564 100644 --- a/backend/README.md +++ b/backend/README.md @@ -77,7 +77,7 @@ Clerk is used for user authentication (optional for local/self-host in many setu ## Database migrations (Alembic) -Migrations live in `backend/alembic/versions/*`. +Migrations live in `backend/migrations/versions/*`. Common commands: diff --git a/backend/alembic.ini b/backend/alembic.ini index 500bf158..b345ff47 100644 --- a/backend/alembic.ini +++ b/backend/alembic.ini @@ -1,5 +1,5 @@ [alembic] -script_location = alembic +script_location = migrations prepend_sys_path = . sqlalchemy.url = driver://user:pass@localhost/dbname diff --git a/backend/alembic/versions/050c16fde00e_backfill_invite_access.py b/backend/alembic/versions/050c16fde00e_backfill_invite_access.py deleted file mode 100644 index 6842d15f..00000000 --- a/backend/alembic/versions/050c16fde00e_backfill_invite_access.py +++ /dev/null @@ -1,89 +0,0 @@ -"""backfill_invite_access - -Revision ID: 050c16fde00e -Revises: 2c7b1c4d9e10 -Create Date: 2026-02-08 20:07:14.621575 - -""" -from __future__ import annotations - -from datetime import datetime -import uuid - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '050c16fde00e' -down_revision = '2c7b1c4d9e10' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - bind = op.get_bind() - now = datetime.utcnow() - rows = bind.execute( - sa.text( - """ - SELECT - m.id AS member_id, - iba.board_id AS board_id, - iba.can_read AS can_read, - iba.can_write AS can_write - FROM organization_invites i - JOIN organization_invite_board_access iba - ON iba.organization_invite_id = i.id - JOIN organization_members m - ON m.user_id = i.accepted_by_user_id - AND m.organization_id = i.organization_id - WHERE i.accepted_at IS NOT NULL - """ - ) - ).fetchall() - - for row in rows: - can_write = bool(row.can_write) - can_read = bool(row.can_read or row.can_write) - bind.execute( - sa.text( - """ - INSERT INTO organization_board_access ( - id, - organization_member_id, - board_id, - can_read, - can_write, - created_at, - updated_at - ) - VALUES ( - :id, - :member_id, - :board_id, - :can_read, - :can_write, - :now, - :now - ) - ON CONFLICT (organization_member_id, board_id) DO UPDATE - SET - can_read = organization_board_access.can_read OR EXCLUDED.can_read, - can_write = organization_board_access.can_write OR EXCLUDED.can_write, - updated_at = EXCLUDED.updated_at - """ - ), - { - "id": uuid.uuid4(), - "member_id": row.member_id, - "board_id": row.board_id, - "can_read": can_read, - "can_write": can_write, - "now": now, - }, - ) - - -def downgrade() -> None: - pass diff --git a/backend/alembic/versions/12772fdcdfe9_board_groups.py b/backend/alembic/versions/12772fdcdfe9_board_groups.py deleted file mode 100644 index f3b2bac3..00000000 --- a/backend/alembic/versions/12772fdcdfe9_board_groups.py +++ /dev/null @@ -1,54 +0,0 @@ -"""board groups - -Revision ID: 12772fdcdfe9 -Revises: 9f0c4fb2a7b8 -Create Date: 2026-02-07 17:13:50.597099 - -""" -from __future__ import annotations - -from alembic import op -import sqlalchemy as sa -import sqlmodel - - -# revision identifiers, used by Alembic. -revision = "12772fdcdfe9" -down_revision = "9f0c4fb2a7b8" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_table( - "board_groups", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("slug", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_board_groups_slug", "board_groups", ["slug"], unique=False) - - op.add_column("boards", sa.Column("board_group_id", sa.Uuid(), nullable=True)) - op.create_index("ix_boards_board_group_id", "boards", ["board_group_id"], unique=False) - op.create_foreign_key( - "fk_boards_board_group_id_board_groups", - "boards", - "board_groups", - ["board_group_id"], - ["id"], - ) - - -def downgrade() -> None: - op.drop_constraint( - "fk_boards_board_group_id_board_groups", "boards", type_="foreignkey" - ) - op.drop_index("ix_boards_board_group_id", table_name="boards") - op.drop_column("boards", "board_group_id") - - op.drop_index("ix_board_groups_slug", table_name="board_groups") - op.drop_table("board_groups") diff --git a/backend/alembic/versions/1d844b04ee06_init.py b/backend/alembic/versions/1d844b04ee06_init.py deleted file mode 100644 index 341b31b9..00000000 --- a/backend/alembic/versions/1d844b04ee06_init.py +++ /dev/null @@ -1,388 +0,0 @@ -"""init (squashed) - -Revision ID: 1d844b04ee06 -Revises: -Create Date: 2026-02-06 - -This is a squashed init migration representing the current schema at revision -`1d844b04ee06`. - -Note: older Alembic revision files were consolidated into this single revision. -Databases already stamped/applied at `1d844b04ee06` will remain compatible. -""" - -from __future__ import annotations - -from alembic import op -import sqlalchemy as sa -import sqlmodel - -# revision identifiers, used by Alembic. -revision = "1d844b04ee06" -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_table( - "gateways", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("url", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("main_session_key", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("workspace_root", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column( - "skyll_enabled", - sa.Boolean(), - server_default=sa.text("false"), - nullable=False, - ), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - - op.create_table( - "boards", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("slug", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("gateway_id", sa.Uuid(), nullable=True), - sa.Column( - "board_type", - sa.String(), - server_default="goal", - nullable=False, - ), - sa.Column("objective", sa.Text(), nullable=True), - sa.Column("success_metrics", sa.JSON(), nullable=True), - sa.Column("target_date", sa.DateTime(), nullable=True), - sa.Column( - "goal_confirmed", - sa.Boolean(), - server_default=sa.text("false"), - nullable=False, - ), - sa.Column("goal_source", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["gateway_id"], ["gateways.id"]), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_boards_slug", "boards", ["slug"], unique=False) - op.create_index("ix_boards_gateway_id", "boards", ["gateway_id"], unique=False) - op.create_index("ix_boards_board_type", "boards", ["board_type"], unique=False) - - op.create_table( - "users", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("clerk_user_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("preferred_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("pronouns", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("timezone", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("context", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("is_super_admin", sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_users_clerk_user_id", "users", ["clerk_user_id"], unique=True) - op.create_index("ix_users_email", "users", ["email"], unique=False) - - op.create_table( - "agents", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("board_id", sa.Uuid(), nullable=True), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("openclaw_session_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("agent_token_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("heartbeat_config", sa.JSON(), nullable=True), - sa.Column("identity_profile", sa.JSON(), nullable=True), - sa.Column("identity_template", sa.Text(), nullable=True), - sa.Column("soul_template", sa.Text(), nullable=True), - sa.Column("provision_requested_at", sa.DateTime(), nullable=True), - sa.Column( - "provision_confirm_token_hash", - sqlmodel.sql.sqltypes.AutoString(), - nullable=True, - ), - sa.Column("provision_action", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("delete_requested_at", sa.DateTime(), nullable=True), - sa.Column( - "delete_confirm_token_hash", - sqlmodel.sql.sqltypes.AutoString(), - nullable=True, - ), - sa.Column("last_seen_at", sa.DateTime(), nullable=True), - sa.Column( - "is_board_lead", - sa.Boolean(), - server_default=sa.text("false"), - nullable=False, - ), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["board_id"], ["boards.id"]), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_agents_board_id", "agents", ["board_id"], unique=False) - op.create_index("ix_agents_name", "agents", ["name"], unique=False) - op.create_index("ix_agents_status", "agents", ["status"], unique=False) - op.create_index( - "ix_agents_openclaw_session_id", "agents", ["openclaw_session_id"], unique=False - ) - op.create_index("ix_agents_agent_token_hash", "agents", ["agent_token_hash"], unique=False) - op.create_index( - "ix_agents_provision_confirm_token_hash", - "agents", - ["provision_confirm_token_hash"], - unique=False, - ) - op.create_index("ix_agents_provision_action", "agents", ["provision_action"], unique=False) - op.create_index( - "ix_agents_delete_confirm_token_hash", - "agents", - ["delete_confirm_token_hash"], - unique=False, - ) - op.create_index("ix_agents_is_board_lead", "agents", ["is_board_lead"], unique=False) - - op.create_table( - "tasks", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("board_id", sa.Uuid(), nullable=True), - sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("priority", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("due_at", sa.DateTime(), nullable=True), - sa.Column("in_progress_at", sa.DateTime(), nullable=True), - sa.Column("created_by_user_id", sa.Uuid(), nullable=True), - sa.Column("assigned_agent_id", sa.Uuid(), nullable=True), - sa.Column( - "auto_created", - sa.Boolean(), - server_default=sa.text("false"), - nullable=False, - ), - sa.Column("auto_reason", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["assigned_agent_id"], ["agents.id"]), - sa.ForeignKeyConstraint(["board_id"], ["boards.id"]), - sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_tasks_board_id", "tasks", ["board_id"], unique=False) - op.create_index("ix_tasks_status", "tasks", ["status"], unique=False) - op.create_index("ix_tasks_priority", "tasks", ["priority"], unique=False) - op.create_index("ix_tasks_due_at", "tasks", ["due_at"], unique=False) - op.create_index("ix_tasks_assigned_agent_id", "tasks", ["assigned_agent_id"], unique=False) - op.create_index("ix_tasks_created_by_user_id", "tasks", ["created_by_user_id"], unique=False) - - op.create_table( - "activity_events", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("event_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("message", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("agent_id", sa.Uuid(), nullable=True), - sa.Column("task_id", sa.Uuid(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["agent_id"], ["agents.id"]), - sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_activity_events_event_type", "activity_events", ["event_type"], unique=False) - op.create_index("ix_activity_events_agent_id", "activity_events", ["agent_id"], unique=False) - op.create_index("ix_activity_events_task_id", "activity_events", ["task_id"], unique=False) - - op.create_table( - "board_memory", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("board_id", sa.Uuid(), nullable=False), - sa.Column("content", sa.Text(), nullable=False), - sa.Column("tags", sa.JSON(), nullable=True), - sa.Column( - "is_chat", - sa.Boolean(), - server_default=sa.text("false"), - nullable=False, - ), - sa.Column("source", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["board_id"], ["boards.id"]), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_board_memory_board_id", "board_memory", ["board_id"], unique=False) - op.create_index("ix_board_memory_is_chat", "board_memory", ["is_chat"], unique=False) - op.create_index( - "ix_board_memory_board_id_is_chat_created_at", - "board_memory", - ["board_id", "is_chat", "created_at"], - unique=False, - ) - - op.create_table( - "approvals", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("board_id", sa.Uuid(), nullable=False), - sa.Column("task_id", sa.Uuid(), nullable=True), - sa.Column("agent_id", sa.Uuid(), nullable=True), - sa.Column("action_type", sa.String(), nullable=False), - sa.Column("payload", sa.JSON(), nullable=True), - sa.Column("confidence", sa.Integer(), nullable=False), - sa.Column("rubric_scores", sa.JSON(), nullable=True), - sa.Column("status", sa.String(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("resolved_at", sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(["agent_id"], ["agents.id"]), - sa.ForeignKeyConstraint(["board_id"], ["boards.id"]), - sa.ForeignKeyConstraint(["task_id"], ["tasks.id"], ondelete="SET NULL"), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_approvals_board_id", "approvals", ["board_id"], unique=False) - op.create_index("ix_approvals_agent_id", "approvals", ["agent_id"], unique=False) - op.create_index("ix_approvals_task_id", "approvals", ["task_id"], unique=False) - op.create_index("ix_approvals_status", "approvals", ["status"], unique=False) - - op.create_table( - "board_onboarding_sessions", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("board_id", sa.Uuid(), nullable=False), - sa.Column("session_key", sa.String(), nullable=False), - sa.Column( - "status", - sa.String(), - server_default="active", - nullable=False, - ), - sa.Column("messages", sa.JSON(), nullable=True), - sa.Column("draft_goal", sa.JSON(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["board_id"], ["boards.id"]), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - "ix_board_onboarding_sessions_board_id", - "board_onboarding_sessions", - ["board_id"], - unique=False, - ) - op.create_index( - "ix_board_onboarding_sessions_status", - "board_onboarding_sessions", - ["status"], - unique=False, - ) - - op.create_table( - "task_fingerprints", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("board_id", sa.Uuid(), nullable=False), - sa.Column("fingerprint_hash", sa.String(), nullable=False), - sa.Column("task_id", sa.Uuid(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["board_id"], ["boards.id"]), - sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - "ix_task_fingerprints_board_hash", - "task_fingerprints", - ["board_id", "fingerprint_hash"], - unique=True, - ) - op.create_index( - "ix_task_fingerprints_board_id", - "task_fingerprints", - ["board_id"], - unique=False, - ) - op.create_index( - "ix_task_fingerprints_fingerprint_hash", - "task_fingerprints", - ["fingerprint_hash"], - unique=False, - ) - op.create_index( - "ix_task_fingerprints_task_id", - "task_fingerprints", - ["task_id"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index("ix_task_fingerprints_task_id", table_name="task_fingerprints") - op.drop_index( - "ix_task_fingerprints_fingerprint_hash", - table_name="task_fingerprints", - ) - op.drop_index("ix_task_fingerprints_board_id", table_name="task_fingerprints") - op.drop_index("ix_task_fingerprints_board_hash", table_name="task_fingerprints") - op.drop_table("task_fingerprints") - - op.drop_index( - "ix_board_onboarding_sessions_status", - table_name="board_onboarding_sessions", - ) - op.drop_index( - "ix_board_onboarding_sessions_board_id", - table_name="board_onboarding_sessions", - ) - op.drop_table("board_onboarding_sessions") - - op.drop_index("ix_approvals_status", table_name="approvals") - op.drop_index("ix_approvals_task_id", table_name="approvals") - op.drop_index("ix_approvals_agent_id", table_name="approvals") - op.drop_index("ix_approvals_board_id", table_name="approvals") - op.drop_table("approvals") - - op.drop_index( - "ix_board_memory_board_id_is_chat_created_at", - table_name="board_memory", - ) - op.drop_index("ix_board_memory_is_chat", table_name="board_memory") - op.drop_index("ix_board_memory_board_id", table_name="board_memory") - op.drop_table("board_memory") - - op.drop_index("ix_activity_events_task_id", table_name="activity_events") - op.drop_index("ix_activity_events_agent_id", table_name="activity_events") - op.drop_index("ix_activity_events_event_type", table_name="activity_events") - op.drop_table("activity_events") - - op.drop_index("ix_tasks_created_by_user_id", table_name="tasks") - op.drop_index("ix_tasks_assigned_agent_id", table_name="tasks") - op.drop_index("ix_tasks_due_at", table_name="tasks") - op.drop_index("ix_tasks_priority", table_name="tasks") - op.drop_index("ix_tasks_status", table_name="tasks") - op.drop_index("ix_tasks_board_id", table_name="tasks") - op.drop_table("tasks") - - op.drop_index("ix_agents_is_board_lead", table_name="agents") - op.drop_index("ix_agents_delete_confirm_token_hash", table_name="agents") - op.drop_index("ix_agents_provision_action", table_name="agents") - op.drop_index("ix_agents_provision_confirm_token_hash", table_name="agents") - op.drop_index("ix_agents_agent_token_hash", table_name="agents") - op.drop_index("ix_agents_openclaw_session_id", table_name="agents") - op.drop_index("ix_agents_status", table_name="agents") - op.drop_index("ix_agents_name", table_name="agents") - op.drop_index("ix_agents_board_id", table_name="agents") - op.drop_table("agents") - - op.drop_index("ix_users_email", table_name="users") - op.drop_index("ix_users_clerk_user_id", table_name="users") - op.drop_table("users") - - op.drop_index("ix_boards_board_type", table_name="boards") - op.drop_index("ix_boards_gateway_id", table_name="boards") - op.drop_index("ix_boards_slug", table_name="boards") - op.drop_table("boards") - - op.drop_table("gateways") - diff --git a/backend/alembic/versions/1f2a3b4c5d6e_add_organizations.py b/backend/alembic/versions/1f2a3b4c5d6e_add_organizations.py deleted file mode 100644 index 1b682b5e..00000000 --- a/backend/alembic/versions/1f2a3b4c5d6e_add_organizations.py +++ /dev/null @@ -1,259 +0,0 @@ -"""add organizations - -Revision ID: 1f2a3b4c5d6e -Revises: 9f0c4fb2a7b8 -Create Date: 2026-02-07 -""" - -from __future__ import annotations - -from datetime import datetime -import uuid - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "1f2a3b4c5d6e" -down_revision = "9f0c4fb2a7b8" -branch_labels = None -depends_on = None - - -DEFAULT_ORG_NAME = "Personal" - - -def upgrade() -> None: - op.create_table( - "organizations", - sa.Column("id", sa.UUID(), primary_key=True, nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.UniqueConstraint("name", name="uq_organizations_name"), - ) - op.create_index("ix_organizations_name", "organizations", ["name"]) - - op.create_table( - "organization_members", - sa.Column("id", sa.UUID(), primary_key=True, nullable=False), - sa.Column("organization_id", sa.UUID(), nullable=False), - sa.Column("user_id", sa.UUID(), nullable=False), - sa.Column("role", sa.String(), nullable=False, server_default="member"), - sa.Column("all_boards_read", sa.Boolean(), nullable=False, server_default=sa.text("false")), - sa.Column("all_boards_write", sa.Boolean(), nullable=False, server_default=sa.text("false")), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"], name="fk_org_members_org"), - sa.ForeignKeyConstraint(["user_id"], ["users.id"], name="fk_org_members_user"), - sa.UniqueConstraint( - "organization_id", - "user_id", - name="uq_organization_members_org_user", - ), - ) - op.create_index("ix_org_members_org", "organization_members", ["organization_id"]) - op.create_index("ix_org_members_user", "organization_members", ["user_id"]) - op.create_index("ix_org_members_role", "organization_members", ["role"]) - - op.create_table( - "organization_board_access", - sa.Column("id", sa.UUID(), primary_key=True, nullable=False), - sa.Column("organization_member_id", sa.UUID(), nullable=False), - sa.Column("board_id", sa.UUID(), nullable=False), - sa.Column("can_read", sa.Boolean(), nullable=False, server_default=sa.text("true")), - sa.Column("can_write", sa.Boolean(), nullable=False, server_default=sa.text("false")), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["organization_member_id"], - ["organization_members.id"], - name="fk_org_board_access_member", - ), - sa.ForeignKeyConstraint(["board_id"], ["boards.id"], name="fk_org_board_access_board"), - sa.UniqueConstraint( - "organization_member_id", - "board_id", - name="uq_org_board_access_member_board", - ), - ) - op.create_index( - "ix_org_board_access_member", - "organization_board_access", - ["organization_member_id"], - ) - op.create_index( - "ix_org_board_access_board", - "organization_board_access", - ["board_id"], - ) - - op.create_table( - "organization_invites", - sa.Column("id", sa.UUID(), primary_key=True, nullable=False), - sa.Column("organization_id", sa.UUID(), nullable=False), - sa.Column("invited_email", sa.String(), nullable=False), - sa.Column("token", sa.String(), nullable=False), - sa.Column("role", sa.String(), nullable=False, server_default="member"), - sa.Column("all_boards_read", sa.Boolean(), nullable=False, server_default=sa.text("false")), - sa.Column("all_boards_write", sa.Boolean(), nullable=False, server_default=sa.text("false")), - sa.Column("created_by_user_id", sa.UUID(), nullable=True), - sa.Column("accepted_by_user_id", sa.UUID(), nullable=True), - sa.Column("accepted_at", sa.DateTime(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"], name="fk_org_invites_org"), - sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_org_invites_creator"), - sa.ForeignKeyConstraint(["accepted_by_user_id"], ["users.id"], name="fk_org_invites_acceptor"), - sa.UniqueConstraint("token", name="uq_org_invites_token"), - ) - op.create_index("ix_org_invites_org", "organization_invites", ["organization_id"]) - op.create_index("ix_org_invites_email", "organization_invites", ["invited_email"]) - op.create_index("ix_org_invites_token", "organization_invites", ["token"]) - - op.create_table( - "organization_invite_board_access", - sa.Column("id", sa.UUID(), primary_key=True, nullable=False), - sa.Column("organization_invite_id", sa.UUID(), nullable=False), - sa.Column("board_id", sa.UUID(), nullable=False), - sa.Column("can_read", sa.Boolean(), nullable=False, server_default=sa.text("true")), - sa.Column("can_write", sa.Boolean(), nullable=False, server_default=sa.text("false")), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["organization_invite_id"], - ["organization_invites.id"], - name="fk_org_invite_access_invite", - ), - sa.ForeignKeyConstraint(["board_id"], ["boards.id"], name="fk_org_invite_access_board"), - sa.UniqueConstraint( - "organization_invite_id", - "board_id", - name="uq_org_invite_board_access_invite_board", - ), - ) - op.create_index( - "ix_org_invite_access_invite", - "organization_invite_board_access", - ["organization_invite_id"], - ) - op.create_index( - "ix_org_invite_access_board", - "organization_invite_board_access", - ["board_id"], - ) - - op.add_column("boards", sa.Column("organization_id", sa.UUID(), nullable=True)) - op.add_column("board_groups", sa.Column("organization_id", sa.UUID(), nullable=True)) - op.add_column("gateways", sa.Column("organization_id", sa.UUID(), nullable=True)) - - op.create_index("ix_boards_organization_id", "boards", ["organization_id"]) - op.create_index("ix_board_groups_organization_id", "board_groups", ["organization_id"]) - op.create_index("ix_gateways_organization_id", "gateways", ["organization_id"]) - - op.create_foreign_key( - "fk_boards_organization_id", - "boards", - "organizations", - ["organization_id"], - ["id"], - ) - op.create_foreign_key( - "fk_board_groups_organization_id", - "board_groups", - "organizations", - ["organization_id"], - ["id"], - ) - op.create_foreign_key( - "fk_gateways_organization_id", - "gateways", - "organizations", - ["organization_id"], - ["id"], - ) - - bind = op.get_bind() - now = datetime.utcnow() - org_id = uuid.uuid4() - bind.execute( - sa.text( - "INSERT INTO organizations (id, name, created_at, updated_at) VALUES (:id, :name, :now, :now)" - ), - {"id": org_id, "name": DEFAULT_ORG_NAME, "now": now}, - ) - - bind.execute( - sa.text("UPDATE boards SET organization_id = :org_id"), - {"org_id": org_id}, - ) - bind.execute( - sa.text("UPDATE board_groups SET organization_id = :org_id"), - {"org_id": org_id}, - ) - bind.execute( - sa.text("UPDATE gateways SET organization_id = :org_id"), - {"org_id": org_id}, - ) - - user_rows = list(bind.execute(sa.text("SELECT id FROM users"))) - for row in user_rows: - user_id = row[0] - bind.execute( - sa.text( - """ - INSERT INTO organization_members - (id, organization_id, user_id, role, all_boards_read, all_boards_write, created_at, updated_at) - VALUES - (:id, :org_id, :user_id, :role, :all_read, :all_write, :now, :now) - """ - ), - { - "id": uuid.uuid4(), - "org_id": org_id, - "user_id": user_id, - "role": "owner", - "all_read": True, - "all_write": True, - "now": now, - }, - ) - - op.alter_column("boards", "organization_id", nullable=False) - op.alter_column("board_groups", "organization_id", nullable=False) - op.alter_column("gateways", "organization_id", nullable=False) - - -def downgrade() -> None: - op.drop_constraint("fk_gateways_organization_id", "gateways", type_="foreignkey") - op.drop_constraint("fk_board_groups_organization_id", "board_groups", type_="foreignkey") - op.drop_constraint("fk_boards_organization_id", "boards", type_="foreignkey") - - op.drop_index("ix_gateways_organization_id", table_name="gateways") - op.drop_index("ix_board_groups_organization_id", table_name="board_groups") - op.drop_index("ix_boards_organization_id", table_name="boards") - - op.drop_column("gateways", "organization_id") - op.drop_column("board_groups", "organization_id") - op.drop_column("boards", "organization_id") - - op.drop_index("ix_org_invite_access_board", table_name="organization_invite_board_access") - op.drop_index("ix_org_invite_access_invite", table_name="organization_invite_board_access") - op.drop_table("organization_invite_board_access") - - op.drop_index("ix_org_invites_token", table_name="organization_invites") - op.drop_index("ix_org_invites_email", table_name="organization_invites") - op.drop_index("ix_org_invites_org", table_name="organization_invites") - op.drop_table("organization_invites") - - op.drop_index("ix_org_board_access_board", table_name="organization_board_access") - op.drop_index("ix_org_board_access_member", table_name="organization_board_access") - op.drop_table("organization_board_access") - - op.drop_index("ix_org_members_role", table_name="organization_members") - op.drop_index("ix_org_members_user", table_name="organization_members") - op.drop_index("ix_org_members_org", table_name="organization_members") - op.drop_table("organization_members") - - op.drop_index("ix_organizations_name", table_name="organizations") - op.drop_table("organizations") diff --git a/backend/alembic/versions/23c771c93430_board_group_memory.py b/backend/alembic/versions/23c771c93430_board_group_memory.py deleted file mode 100644 index a2c8e46f..00000000 --- a/backend/alembic/versions/23c771c93430_board_group_memory.py +++ /dev/null @@ -1,122 +0,0 @@ -"""board group memory - -Revision ID: 23c771c93430 -Revises: 12772fdcdfe9 -Create Date: 2026-02-07 18:00:19.065861 - -""" -from __future__ import annotations - -from alembic import op -import sqlalchemy as sa -import sqlmodel - - -# revision identifiers, used by Alembic. -revision = "23c771c93430" -down_revision = "12772fdcdfe9" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # Repair drift: it's possible to end up with alembic_version stamped at 12772fdcdfe9 - # without actually applying the board groups schema changes. This migration makes the - # required board_groups + boards.board_group_id objects exist before adding group memory. - conn = op.get_bind() - inspector = sa.inspect(conn) - - if not inspector.has_table("board_groups"): - op.create_table( - "board_groups", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("slug", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_board_groups_slug", "board_groups", ["slug"], unique=False) - else: - indexes = {idx.get("name") for idx in inspector.get_indexes("board_groups")} - if "ix_board_groups_slug" not in indexes: - op.create_index("ix_board_groups_slug", "board_groups", ["slug"], unique=False) - - inspector = sa.inspect(conn) - board_cols = {col.get("name") for col in inspector.get_columns("boards")} - if "board_group_id" not in board_cols: - op.add_column("boards", sa.Column("board_group_id", sa.Uuid(), nullable=True)) - - inspector = sa.inspect(conn) - board_indexes = {idx.get("name") for idx in inspector.get_indexes("boards")} - if "ix_boards_board_group_id" not in board_indexes: - op.create_index("ix_boards_board_group_id", "boards", ["board_group_id"], unique=False) - - def _has_board_groups_fk() -> bool: - for fk in inspector.get_foreign_keys("boards"): - if fk.get("referred_table") != "board_groups": - continue - if fk.get("constrained_columns") != ["board_group_id"]: - continue - if fk.get("referred_columns") != ["id"]: - continue - return True - return False - - if not _has_board_groups_fk(): - op.create_foreign_key( - "fk_boards_board_group_id_board_groups", - "boards", - "board_groups", - ["board_group_id"], - ["id"], - ) - - inspector = sa.inspect(conn) - if not inspector.has_table("board_group_memory"): - op.create_table( - "board_group_memory", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("board_group_id", sa.Uuid(), nullable=False), - sa.Column("content", sa.Text(), nullable=False), - sa.Column("tags", sa.JSON(), nullable=True), - sa.Column( - "is_chat", - sa.Boolean(), - server_default=sa.text("false"), - nullable=False, - ), - sa.Column("source", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["board_group_id"], ["board_groups.id"]), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - "ix_board_group_memory_board_group_id", - "board_group_memory", - ["board_group_id"], - unique=False, - ) - op.create_index( - "ix_board_group_memory_is_chat", - "board_group_memory", - ["is_chat"], - unique=False, - ) - op.create_index( - "ix_board_group_memory_board_group_id_is_chat_created_at", - "board_group_memory", - ["board_group_id", "is_chat", "created_at"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index( - "ix_board_group_memory_board_group_id_is_chat_created_at", - table_name="board_group_memory", - ) - op.drop_index("ix_board_group_memory_is_chat", table_name="board_group_memory") - op.drop_index("ix_board_group_memory_board_group_id", table_name="board_group_memory") - op.drop_table("board_group_memory") diff --git a/backend/alembic/versions/2c7b1c4d9e10_merge_heads.py b/backend/alembic/versions/2c7b1c4d9e10_merge_heads.py deleted file mode 100644 index 64aba225..00000000 --- a/backend/alembic/versions/2c7b1c4d9e10_merge_heads.py +++ /dev/null @@ -1,24 +0,0 @@ -"""merge heads - -Revision ID: 2c7b1c4d9e10 -Revises: 1f2a3b4c5d6e, af403671a8c4 -Create Date: 2026-02-07 -""" - -from __future__ import annotations - -from alembic import op - -# revision identifiers, used by Alembic. -revision = "2c7b1c4d9e10" -down_revision = ("1f2a3b4c5d6e", "af403671a8c4") -branch_labels = None -depends_on = None - - -def upgrade() -> None: - pass - - -def downgrade() -> None: - pass diff --git a/backend/alembic/versions/3c6a2d3df4a1_task_dependencies.py b/backend/alembic/versions/3c6a2d3df4a1_task_dependencies.py deleted file mode 100644 index 9f67379e..00000000 --- a/backend/alembic/versions/3c6a2d3df4a1_task_dependencies.py +++ /dev/null @@ -1,80 +0,0 @@ -"""task dependencies - -Revision ID: 3c6a2d3df4a1 -Revises: 1d844b04ee06 -Create Date: 2026-02-06 -""" - -from __future__ import annotations - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "3c6a2d3df4a1" -down_revision = "1d844b04ee06" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_table( - "task_dependencies", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("board_id", sa.Uuid(), nullable=False), - sa.Column("task_id", sa.Uuid(), nullable=False), - sa.Column("depends_on_task_id", sa.Uuid(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["board_id"], - ["boards.id"], - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["task_id"], - ["tasks.id"], - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["depends_on_task_id"], - ["tasks.id"], - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "task_id", - "depends_on_task_id", - name="uq_task_dependencies_task_id_depends_on_task_id", - ), - sa.CheckConstraint( - "task_id <> depends_on_task_id", - name="ck_task_dependencies_no_self", - ), - ) - - op.create_index( - "ix_task_dependencies_board_id", - "task_dependencies", - ["board_id"], - unique=False, - ) - op.create_index( - "ix_task_dependencies_task_id", - "task_dependencies", - ["task_id"], - unique=False, - ) - op.create_index( - "ix_task_dependencies_depends_on_task_id", - "task_dependencies", - ["depends_on_task_id"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index("ix_task_dependencies_depends_on_task_id", table_name="task_dependencies") - op.drop_index("ix_task_dependencies_task_id", table_name="task_dependencies") - op.drop_index("ix_task_dependencies_board_id", table_name="task_dependencies") - op.drop_table("task_dependencies") - diff --git a/backend/alembic/versions/5fb3b2491090_ensure_board_group_memory_table.py b/backend/alembic/versions/5fb3b2491090_ensure_board_group_memory_table.py deleted file mode 100644 index 81d2d549..00000000 --- a/backend/alembic/versions/5fb3b2491090_ensure_board_group_memory_table.py +++ /dev/null @@ -1,67 +0,0 @@ -"""ensure board group memory table - -Revision ID: 5fb3b2491090 -Revises: 23c771c93430 -Create Date: 2026-02-07 18:07:20.588662 - -""" -from __future__ import annotations - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "5fb3b2491090" -down_revision = "23c771c93430" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - conn = op.get_bind() - inspector = sa.inspect(conn) - if inspector.has_table("board_group_memory"): - return - - op.create_table( - "board_group_memory", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("board_group_id", sa.Uuid(), nullable=False), - sa.Column("content", sa.Text(), nullable=False), - sa.Column("tags", sa.JSON(), nullable=True), - sa.Column( - "is_chat", - sa.Boolean(), - server_default=sa.text("false"), - nullable=False, - ), - sa.Column("source", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["board_group_id"], ["board_groups.id"]), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - "ix_board_group_memory_board_group_id", - "board_group_memory", - ["board_group_id"], - unique=False, - ) - op.create_index( - "ix_board_group_memory_is_chat", - "board_group_memory", - ["is_chat"], - unique=False, - ) - op.create_index( - "ix_board_group_memory_board_group_id_is_chat_created_at", - "board_group_memory", - ["board_group_id", "is_chat", "created_at"], - unique=False, - ) - - -def downgrade() -> None: - # This is a repair migration. Downgrading from 5fb3b2491090 -> 23c771c93430 - # should keep the board_group_memory table (it belongs to the prior revision). - return diff --git a/backend/alembic/versions/6e1c9b2f7a4d_add_active_organization_id.py b/backend/alembic/versions/6e1c9b2f7a4d_add_active_organization_id.py deleted file mode 100644 index 5badd6ea..00000000 --- a/backend/alembic/versions/6e1c9b2f7a4d_add_active_organization_id.py +++ /dev/null @@ -1,70 +0,0 @@ -"""add active organization to users - -Revision ID: 6e1c9b2f7a4d -Revises: 050c16fde00e -Create Date: 2026-02-08 -""" - -from __future__ import annotations - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "6e1c9b2f7a4d" -down_revision = "050c16fde00e" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.add_column( - "users", - sa.Column("active_organization_id", sa.UUID(), nullable=True), - ) - op.create_index( - "ix_users_active_organization_id", - "users", - ["active_organization_id"], - ) - op.create_foreign_key( - "fk_users_active_organization", - "users", - "organizations", - ["active_organization_id"], - ["id"], - ) - - bind = op.get_bind() - rows = bind.execute( - sa.text( - """ - SELECT user_id, organization_id - FROM organization_members - ORDER BY user_id, created_at ASC - """ - ) - ).fetchall() - seen: set[str] = set() - for row in rows: - user_id = str(row.user_id) - if user_id in seen: - continue - seen.add(user_id) - bind.execute( - sa.text( - """ - UPDATE users - SET active_organization_id = :org_id - WHERE id = :user_id - AND active_organization_id IS NULL - """ - ), - {"org_id": row.organization_id, "user_id": row.user_id}, - ) - - -def downgrade() -> None: - op.drop_constraint("fk_users_active_organization", "users", type_="foreignkey") - op.drop_index("ix_users_active_organization_id", table_name="users") - op.drop_column("users", "active_organization_id") diff --git a/backend/alembic/versions/9f0c4fb2a7b8_remove_skyll_enabled.py b/backend/alembic/versions/9f0c4fb2a7b8_remove_skyll_enabled.py deleted file mode 100644 index b9b57f40..00000000 --- a/backend/alembic/versions/9f0c4fb2a7b8_remove_skyll_enabled.py +++ /dev/null @@ -1,34 +0,0 @@ -"""remove skyll_enabled - -Revision ID: 9f0c4fb2a7b8 -Revises: 3c6a2d3df4a1 -Create Date: 2026-02-06 -""" - -from __future__ import annotations - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "9f0c4fb2a7b8" -down_revision = "3c6a2d3df4a1" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.drop_column("gateways", "skyll_enabled") - - -def downgrade() -> None: - op.add_column( - "gateways", - sa.Column( - "skyll_enabled", - sa.Boolean(), - server_default=sa.text("false"), - nullable=False, - ), - ) - diff --git a/backend/alembic/versions/af403671a8c4_repair_board_groups_schema.py b/backend/alembic/versions/af403671a8c4_repair_board_groups_schema.py deleted file mode 100644 index a1ee5e8f..00000000 --- a/backend/alembic/versions/af403671a8c4_repair_board_groups_schema.py +++ /dev/null @@ -1,79 +0,0 @@ -"""repair board groups schema - -Revision ID: af403671a8c4 -Revises: 5fb3b2491090 -Create Date: 2026-02-07 - -""" - -from __future__ import annotations - -from alembic import op -import sqlalchemy as sa -import sqlmodel - -# revision identifiers, used by Alembic. -revision = "af403671a8c4" -down_revision = "5fb3b2491090" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # Repair drift: it is possible to end up with alembic_version stamped at (or beyond) - # the board group revisions without having the underlying DB objects present. - conn = op.get_bind() - inspector = sa.inspect(conn) - - if not inspector.has_table("board_groups"): - op.create_table( - "board_groups", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("slug", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_board_groups_slug", "board_groups", ["slug"], unique=False) - else: - indexes = {idx.get("name") for idx in inspector.get_indexes("board_groups")} - if "ix_board_groups_slug" not in indexes: - op.create_index("ix_board_groups_slug", "board_groups", ["slug"], unique=False) - - inspector = sa.inspect(conn) - board_cols = {col.get("name") for col in inspector.get_columns("boards")} - if "board_group_id" not in board_cols: - op.add_column("boards", sa.Column("board_group_id", sa.Uuid(), nullable=True)) - - inspector = sa.inspect(conn) - board_indexes = {idx.get("name") for idx in inspector.get_indexes("boards")} - if "ix_boards_board_group_id" not in board_indexes: - op.create_index("ix_boards_board_group_id", "boards", ["board_group_id"], unique=False) - - def _has_board_groups_fk() -> bool: - for fk in inspector.get_foreign_keys("boards"): - if fk.get("referred_table") != "board_groups": - continue - if fk.get("constrained_columns") != ["board_group_id"]: - continue - if fk.get("referred_columns") != ["id"]: - continue - return True - return False - - if not _has_board_groups_fk(): - op.create_foreign_key( - "fk_boards_board_group_id_board_groups", - "boards", - "board_groups", - ["board_group_id"], - ["id"], - ) - - -def downgrade() -> None: - # Repair migration: do not attempt to undo drift fixes automatically. - return - diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 45e9d0ea..d44a83c3 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -21,6 +21,7 @@ from app.core.auth import AuthContext, get_auth_context from app.core.time import utcnow from app.db.pagination import paginate from app.db.session import async_session_maker, get_session +from app.db.sqlmodel_exec import exec_dml from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message from app.models.activity_events import ActivityEvent @@ -973,7 +974,8 @@ async def delete_agent( agent_id=None, ) now = datetime.now() - await session.execute( + await exec_dml( + session, update(Task) .where(col(Task.assigned_agent_id) == agent.id) .where(col(Task.status) == "in_progress") @@ -982,19 +984,21 @@ async def delete_agent( status="inbox", in_progress_at=None, updated_at=now, - ) + ), ) - await session.execute( + await exec_dml( + session, update(Task) .where(col(Task.assigned_agent_id) == agent.id) .where(col(Task.status) != "in_progress") .values( assigned_agent_id=None, updated_at=now, - ) + ), ) - await session.execute( - update(ActivityEvent).where(col(ActivityEvent.agent_id) == agent.id).values(agent_id=None) + await exec_dml( + session, + update(ActivityEvent).where(col(ActivityEvent.agent_id) == agent.id).values(agent_id=None), ) await session.delete(agent) await session.commit() diff --git a/backend/app/api/board_groups.py b/backend/app/api/board_groups.py index c21b005e..4ee39a4c 100644 --- a/backend/app/api/board_groups.py +++ b/backend/app/api/board_groups.py @@ -14,6 +14,7 @@ from app.core.time import utcnow from app.db import crud from app.db.pagination import paginate from app.db.session import get_session +from app.db.sqlmodel_exec import exec_dml from app.models.agents import Agent from app.models.board_group_memory import BoardGroupMemory from app.models.board_groups import BoardGroup @@ -262,10 +263,8 @@ async def update_board_group( updates = payload.model_dump(exclude_unset=True) if "slug" in updates and updates["slug"] is not None and not updates["slug"].strip(): updates["slug"] = _slugify(updates.get("name") or group.name) - for key, value in updates.items(): - setattr(group, key, value) - group.updated_at = utcnow() - return await crud.save(session, group) + updates["updated_at"] = utcnow() + return await crud.patch(session, group, updates) @router.delete("/{group_id}", response_model=OkResponse) @@ -277,12 +276,14 @@ async def delete_board_group( await _require_group_access(session, group_id=group_id, member=ctx.member, write=True) # Boards reference groups, so clear the FK first to keep deletes simple. - await session.execute( - update(Board).where(col(Board.board_group_id) == group_id).values(board_group_id=None) + await exec_dml( + session, + update(Board).where(col(Board.board_group_id) == group_id).values(board_group_id=None), ) - await session.execute( - delete(BoardGroupMemory).where(col(BoardGroupMemory.board_group_id) == group_id) + await exec_dml( + session, + delete(BoardGroupMemory).where(col(BoardGroupMemory.board_group_id) == group_id), ) - await session.execute(delete(BoardGroup).where(col(BoardGroup.id) == group_id)) + await exec_dml(session, delete(BoardGroup).where(col(BoardGroup.id) == group_id)) await session.commit() return OkResponse() diff --git a/backend/app/api/boards.py b/backend/app/api/boards.py index 974efebc..7753a3ff 100644 --- a/backend/app/api/boards.py +++ b/backend/app/api/boards.py @@ -19,6 +19,7 @@ from app.core.time import utcnow from app.db import crud from app.db.pagination import paginate from app.db.session import get_session +from app.db.sqlmodel_exec import exec_dml from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig from app.integrations.openclaw_gateway import ( OpenClawGatewayError, @@ -140,8 +141,7 @@ async def _apply_board_update( updates["board_group_id"], organization_id=board.organization_id, ) - for key, value in updates.items(): - setattr(board, key, value) + crud.apply_updates(board, updates) if updates.get("board_type") == "goal": # Validate only when explicitly switching to goal boards. if not board.objective or not board.success_metrics: @@ -307,36 +307,43 @@ async def delete_board( ) from exc if task_ids: - await session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id).in_(task_ids))) - await session.execute(delete(TaskDependency).where(col(TaskDependency.board_id) == board.id)) - await session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.board_id) == board.id)) + await exec_dml( + session, delete(ActivityEvent).where(col(ActivityEvent.task_id).in_(task_ids)) + ) + await exec_dml(session, delete(TaskDependency).where(col(TaskDependency.board_id) == board.id)) + await exec_dml( + session, delete(TaskFingerprint).where(col(TaskFingerprint.board_id) == board.id) + ) # Approvals can reference tasks and agents, so delete before both. - await session.execute(delete(Approval).where(col(Approval.board_id) == board.id)) + await exec_dml(session, delete(Approval).where(col(Approval.board_id) == board.id)) - await session.execute(delete(BoardMemory).where(col(BoardMemory.board_id) == board.id)) - await session.execute( - delete(BoardOnboardingSession).where(col(BoardOnboardingSession.board_id) == board.id) + await exec_dml(session, delete(BoardMemory).where(col(BoardMemory.board_id) == board.id)) + await exec_dml( + session, + delete(BoardOnboardingSession).where(col(BoardOnboardingSession.board_id) == board.id), ) - await session.execute( - delete(OrganizationBoardAccess).where(col(OrganizationBoardAccess.board_id) == board.id) + await exec_dml( + session, + delete(OrganizationBoardAccess).where(col(OrganizationBoardAccess.board_id) == board.id), ) - await session.execute( + await exec_dml( + session, delete(OrganizationInviteBoardAccess).where( col(OrganizationInviteBoardAccess.board_id) == board.id - ) + ), ) # Tasks reference agents (assigned_agent_id) and have dependents (fingerprints/dependencies), so # delete tasks before agents. - await session.execute(delete(Task).where(col(Task.board_id) == board.id)) + await exec_dml(session, delete(Task).where(col(Task.board_id) == board.id)) if agents: agent_ids = [agent.id for agent in agents] - await session.execute( - delete(ActivityEvent).where(col(ActivityEvent.agent_id).in_(agent_ids)) + await exec_dml( + session, delete(ActivityEvent).where(col(ActivityEvent.agent_id).in_(agent_ids)) ) - await session.execute(delete(Agent).where(col(Agent.id).in_(agent_ids))) + await exec_dml(session, delete(Agent).where(col(Agent.id).in_(agent_ids))) await session.delete(board) await session.commit() return OkResponse() diff --git a/backend/app/api/gateways.py b/backend/app/api/gateways.py index 3f69441f..a6acf815 100644 --- a/backend/app/api/gateways.py +++ b/backend/app/api/gateways.py @@ -2,14 +2,16 @@ from __future__ import annotations from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, Query from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession from app.api.deps import require_org_admin +from app.api.queryset import api_qs from app.core.agent_tokens import generate_agent_token, hash_agent_token from app.core.auth import AuthContext, get_auth_context from app.core.time import utcnow +from app.db import crud from app.db.pagination import paginate from app.db.session import get_session from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig @@ -35,6 +37,22 @@ def _main_agent_name(gateway: Gateway) -> str: return f"{gateway.name} Main" +async def _require_gateway( + session: AsyncSession, + *, + gateway_id: UUID, + organization_id: UUID, +) -> Gateway: + return await ( + api_qs(Gateway) + .filter( + col(Gateway.id) == gateway_id, + col(Gateway.organization_id) == organization_id, + ) + .first_or_404(session, detail="Gateway not found") + ) + + async def _find_main_agent( session: AsyncSession, gateway: Gateway, @@ -135,9 +153,10 @@ async def list_gateways( ctx: OrganizationContext = Depends(require_org_admin), ) -> DefaultLimitOffsetPage[GatewayRead]: statement = ( - select(Gateway) - .where(col(Gateway.organization_id) == ctx.organization.id) + api_qs(Gateway) + .filter(col(Gateway.organization_id) == ctx.organization.id) .order_by(col(Gateway.created_at).desc()) + .statement ) return await paginate(session, statement) @@ -151,10 +170,7 @@ async def create_gateway( ) -> Gateway: data = payload.model_dump() data["organization_id"] = ctx.organization.id - gateway = Gateway.model_validate(data) - session.add(gateway) - await session.commit() - await session.refresh(gateway) + gateway = await crud.create(session, Gateway, **data) await _ensure_main_agent(session, gateway, auth, action="provision") return gateway @@ -165,10 +181,11 @@ async def get_gateway( session: AsyncSession = Depends(get_session), ctx: OrganizationContext = Depends(require_org_admin), ) -> Gateway: - gateway = await session.get(Gateway, gateway_id) - if gateway is None or gateway.organization_id != ctx.organization.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found") - return gateway + return await _require_gateway( + session, + gateway_id=gateway_id, + organization_id=ctx.organization.id, + ) @router.patch("/{gateway_id}", response_model=GatewayRead) @@ -179,17 +196,15 @@ async def update_gateway( auth: AuthContext = Depends(get_auth_context), ctx: OrganizationContext = Depends(require_org_admin), ) -> Gateway: - gateway = await session.get(Gateway, gateway_id) - if gateway is None or gateway.organization_id != ctx.organization.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found") + gateway = await _require_gateway( + session, + gateway_id=gateway_id, + organization_id=ctx.organization.id, + ) previous_name = gateway.name previous_session_key = gateway.main_session_key updates = payload.model_dump(exclude_unset=True) - for key, value in updates.items(): - setattr(gateway, key, value) - session.add(gateway) - await session.commit() - await session.refresh(gateway) + await crud.patch(session, gateway, updates) await _ensure_main_agent( session, gateway, @@ -213,9 +228,11 @@ async def sync_gateway_templates( auth: AuthContext = Depends(get_auth_context), ctx: OrganizationContext = Depends(require_org_admin), ) -> GatewayTemplatesSyncResult: - gateway = await session.get(Gateway, gateway_id) - if gateway is None or gateway.organization_id != ctx.organization.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found") + gateway = await _require_gateway( + session, + gateway_id=gateway_id, + organization_id=ctx.organization.id, + ) return await sync_gateway_templates_service( session, gateway, @@ -234,9 +251,10 @@ async def delete_gateway( session: AsyncSession = Depends(get_session), ctx: OrganizationContext = Depends(require_org_admin), ) -> OkResponse: - gateway = await session.get(Gateway, gateway_id) - if gateway is None or gateway.organization_id != ctx.organization.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found") - await session.delete(gateway) - await session.commit() + gateway = await _require_gateway( + session, + gateway_id=gateway_id, + organization_id=ctx.organization.id, + ) + await crud.delete(session, gateway) return OkResponse() diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py index 5ff5ace1..73a9c607 100644 --- a/backend/app/api/organizations.py +++ b/backend/app/api/organizations.py @@ -10,10 +10,13 @@ from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession from app.api.deps import require_org_admin, require_org_member +from app.api.queryset import api_qs from app.core.auth import AuthContext, get_auth_context from app.core.time import utcnow +from app.db import crud from app.db.pagination import paginate from app.db.session import get_session +from app.db.sqlmodel_exec import exec_dml from app.models.activity_events import ActivityEvent from app.models.agents import Agent from app.models.approvals import Approval @@ -72,6 +75,38 @@ def _member_to_read(member: OrganizationMember, user: User | None) -> Organizati return model +async def _require_org_member( + session: AsyncSession, + *, + organization_id: UUID, + member_id: UUID, +) -> OrganizationMember: + return await ( + api_qs(OrganizationMember) + .filter( + col(OrganizationMember.id) == member_id, + col(OrganizationMember.organization_id) == organization_id, + ) + .first_or_404(session) + ) + + +async def _require_org_invite( + session: AsyncSession, + *, + organization_id: UUID, + invite_id: UUID, +) -> OrganizationInvite: + return await ( + api_qs(OrganizationInvite) + .filter( + col(OrganizationInvite.id) == invite_id, + col(OrganizationInvite.organization_id) == organization_id, + ) + .first_or_404(session) + ) + + @router.post("", response_model=OrganizationRead) async def create_organization( payload: OrganizationCreate, @@ -188,55 +223,67 @@ async def delete_my_org( ) group_ids = select(BoardGroup.id).where(col(BoardGroup.organization_id) == org_id) - await session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id).in_(task_ids))) - await session.execute(delete(ActivityEvent).where(col(ActivityEvent.agent_id).in_(agent_ids))) - await session.execute(delete(TaskDependency).where(col(TaskDependency.board_id).in_(board_ids))) - await session.execute( - delete(TaskFingerprint).where(col(TaskFingerprint.board_id).in_(board_ids)) + await exec_dml(session, delete(ActivityEvent).where(col(ActivityEvent.task_id).in_(task_ids))) + await exec_dml(session, delete(ActivityEvent).where(col(ActivityEvent.agent_id).in_(agent_ids))) + await exec_dml( + session, delete(TaskDependency).where(col(TaskDependency.board_id).in_(board_ids)) ) - await session.execute(delete(Approval).where(col(Approval.board_id).in_(board_ids))) - await session.execute(delete(BoardMemory).where(col(BoardMemory.board_id).in_(board_ids))) - await session.execute( - delete(BoardOnboardingSession).where(col(BoardOnboardingSession.board_id).in_(board_ids)) + await exec_dml( + session, + delete(TaskFingerprint).where(col(TaskFingerprint.board_id).in_(board_ids)), ) - await session.execute( - delete(OrganizationBoardAccess).where(col(OrganizationBoardAccess.board_id).in_(board_ids)) + await exec_dml(session, delete(Approval).where(col(Approval.board_id).in_(board_ids))) + await exec_dml(session, delete(BoardMemory).where(col(BoardMemory.board_id).in_(board_ids))) + await exec_dml( + session, + delete(BoardOnboardingSession).where(col(BoardOnboardingSession.board_id).in_(board_ids)), ) - await session.execute( + await exec_dml( + session, + delete(OrganizationBoardAccess).where(col(OrganizationBoardAccess.board_id).in_(board_ids)), + ) + await exec_dml( + session, delete(OrganizationInviteBoardAccess).where( col(OrganizationInviteBoardAccess.board_id).in_(board_ids) - ) + ), ) - await session.execute( + await exec_dml( + session, delete(OrganizationBoardAccess).where( col(OrganizationBoardAccess.organization_member_id).in_(member_ids) - ) + ), ) - await session.execute( + await exec_dml( + session, delete(OrganizationInviteBoardAccess).where( col(OrganizationInviteBoardAccess.organization_invite_id).in_(invite_ids) - ) + ), ) - await session.execute(delete(Task).where(col(Task.board_id).in_(board_ids))) - await session.execute(delete(Agent).where(col(Agent.board_id).in_(board_ids))) - await session.execute(delete(Board).where(col(Board.organization_id) == org_id)) - await session.execute( - delete(BoardGroupMemory).where(col(BoardGroupMemory.board_group_id).in_(group_ids)) + await exec_dml(session, delete(Task).where(col(Task.board_id).in_(board_ids))) + await exec_dml(session, delete(Agent).where(col(Agent.board_id).in_(board_ids))) + await exec_dml(session, delete(Board).where(col(Board.organization_id) == org_id)) + await exec_dml( + session, + delete(BoardGroupMemory).where(col(BoardGroupMemory.board_group_id).in_(group_ids)), ) - await session.execute(delete(BoardGroup).where(col(BoardGroup.organization_id) == org_id)) - await session.execute(delete(Gateway).where(col(Gateway.organization_id) == org_id)) - await session.execute( - delete(OrganizationInvite).where(col(OrganizationInvite.organization_id) == org_id) + await exec_dml(session, delete(BoardGroup).where(col(BoardGroup.organization_id) == org_id)) + await exec_dml(session, delete(Gateway).where(col(Gateway.organization_id) == org_id)) + await exec_dml( + session, + delete(OrganizationInvite).where(col(OrganizationInvite.organization_id) == org_id), ) - await session.execute( - delete(OrganizationMember).where(col(OrganizationMember.organization_id) == org_id) + await exec_dml( + session, + delete(OrganizationMember).where(col(OrganizationMember.organization_id) == org_id), ) - await session.execute( + await exec_dml( + session, update(User) .where(col(User.active_organization_id) == org_id) - .values(active_organization_id=None) + .values(active_organization_id=None), ) - await session.execute(delete(Organization).where(col(Organization.id) == org_id)) + await exec_dml(session, delete(Organization).where(col(Organization.id) == org_id)) await session.commit() return OkResponse() @@ -288,9 +335,11 @@ async def get_org_member( session: AsyncSession = Depends(get_session), ctx: OrganizationContext = Depends(require_org_member), ) -> OrganizationMemberRead: - member = await session.get(OrganizationMember, member_id) - if member is None or member.organization_id != ctx.organization.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + member = await _require_org_member( + session, + organization_id=ctx.organization.id, + member_id=member_id, + ) if not is_org_admin(ctx.member) and member.user_id != ctx.member.user_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) user = await session.get(User, member.user_id) @@ -315,16 +364,16 @@ async def update_org_member( session: AsyncSession = Depends(get_session), ctx: OrganizationContext = Depends(require_org_admin), ) -> OrganizationMemberRead: - member = await session.get(OrganizationMember, member_id) - if member is None or member.organization_id != ctx.organization.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + member = await _require_org_member( + session, + organization_id=ctx.organization.id, + member_id=member_id, + ) updates = payload.model_dump(exclude_unset=True) if "role" in updates and updates["role"] is not None: - member.role = normalize_role(updates["role"]) - member.updated_at = utcnow() - session.add(member) - await session.commit() - await session.refresh(member) + updates["role"] = normalize_role(updates["role"]) + updates["updated_at"] = utcnow() + member = await crud.patch(session, member, updates) user = await session.get(User, member.user_id) return _member_to_read(member, user) @@ -336,9 +385,11 @@ async def update_member_access( session: AsyncSession = Depends(get_session), ctx: OrganizationContext = Depends(require_org_admin), ) -> OrganizationMemberRead: - member = await session.get(OrganizationMember, member_id) - if member is None or member.organization_id != ctx.organization.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + member = await _require_org_member( + session, + organization_id=ctx.organization.id, + member_id=member_id, + ) board_ids = {entry.board_id for entry in payload.board_access} if board_ids: @@ -393,10 +444,11 @@ async def remove_org_member( detail="Organization must have at least one owner", ) - await session.execute( + await exec_dml( + session, delete(OrganizationBoardAccess).where( col(OrganizationBoardAccess.organization_member_id) == member.id - ) + ), ) user = await session.get(User, member.user_id) @@ -412,8 +464,7 @@ async def remove_org_member( user.active_organization_id = fallback_org_id session.add(user) - await session.delete(member) - await session.commit() + await crud.delete(session, member) return OkResponse() @@ -423,10 +474,11 @@ async def list_org_invites( ctx: OrganizationContext = Depends(require_org_admin), ) -> DefaultLimitOffsetPage[OrganizationInviteRead]: statement = ( - select(OrganizationInvite) - .where(col(OrganizationInvite.organization_id) == ctx.organization.id) - .where(col(OrganizationInvite.accepted_at).is_(None)) + api_qs(OrganizationInvite) + .filter(col(OrganizationInvite.organization_id) == ctx.organization.id) + .filter(col(OrganizationInvite.accepted_at).is_(None)) .order_by(col(OrganizationInvite.created_at).desc()) + .statement ) return await paginate(session, statement) @@ -491,16 +543,18 @@ async def revoke_org_invite( session: AsyncSession = Depends(get_session), ctx: OrganizationContext = Depends(require_org_admin), ) -> OrganizationInviteRead: - invite = await session.get(OrganizationInvite, invite_id) - if invite is None or invite.organization_id != ctx.organization.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - await session.execute( + invite = await _require_org_invite( + session, + organization_id=ctx.organization.id, + invite_id=invite_id, + ) + await exec_dml( + session, delete(OrganizationInviteBoardAccess).where( col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id ), ) - await session.delete(invite) - await session.commit() + await crud.delete(session, invite) return OrganizationInviteRead.model_validate(invite, from_attributes=True) diff --git a/backend/app/api/queryset.py b/backend/app/api/queryset.py new file mode 100644 index 00000000..8a6cb7e6 --- /dev/null +++ b/backend/app/api/queryset.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +from fastapi import HTTPException, status +from sqlmodel.ext.asyncio.session import AsyncSession +from sqlmodel.sql.expression import SelectOfScalar + +from app.db.queryset import QuerySet, qs + +ModelT = TypeVar("ModelT") + + +@dataclass(frozen=True) +class APIQuerySet(Generic[ModelT]): + queryset: QuerySet[ModelT] + + @property + def statement(self) -> SelectOfScalar[ModelT]: + return self.queryset.statement + + def filter(self, *criteria: Any) -> APIQuerySet[ModelT]: + return APIQuerySet(self.queryset.filter(*criteria)) + + def order_by(self, *ordering: Any) -> APIQuerySet[ModelT]: + return APIQuerySet(self.queryset.order_by(*ordering)) + + def limit(self, value: int) -> APIQuerySet[ModelT]: + return APIQuerySet(self.queryset.limit(value)) + + def offset(self, value: int) -> APIQuerySet[ModelT]: + return APIQuerySet(self.queryset.offset(value)) + + async def all(self, session: AsyncSession) -> list[ModelT]: + return await self.queryset.all(session) + + async def first(self, session: AsyncSession) -> ModelT | None: + return await self.queryset.first(session) + + async def first_or_404( + self, + session: AsyncSession, + *, + detail: str | None = None, + ) -> ModelT: + obj = await self.first(session) + if obj is not None: + return obj + if detail is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) + + +def api_qs(model: type[ModelT]) -> APIQuerySet[ModelT]: + return APIQuerySet(qs(model)) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index f3fd2ca1..281e262f 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -27,6 +27,7 @@ from app.core.auth import AuthContext from app.core.time import utcnow from app.db.pagination import paginate from app.db.session import async_session_maker, get_session +from app.db.sqlmodel_exec import exec_dml from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message from app.models.activity_events import ActivityEvent @@ -990,16 +991,17 @@ async def delete_task( if auth.user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) await require_board_access(session, user=auth.user, board=board, write=True) - await session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id) == task.id)) - await session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.task_id) == task.id)) - await session.execute(delete(Approval).where(col(Approval.task_id) == task.id)) - await session.execute( + await exec_dml(session, delete(ActivityEvent).where(col(ActivityEvent.task_id) == task.id)) + await exec_dml(session, delete(TaskFingerprint).where(col(TaskFingerprint.task_id) == task.id)) + await exec_dml(session, delete(Approval).where(col(Approval.task_id) == task.id)) + await exec_dml( + session, delete(TaskDependency).where( or_( col(TaskDependency.task_id) == task.id, col(TaskDependency.depends_on_task_id) == task.id, ) - ) + ), ) await session.delete(task) await session.commit() diff --git a/backend/app/db/crud.py b/backend/app/db/crud.py index 176886b5..5b789f96 100644 --- a/backend/app/db/crud.py +++ b/backend/app/db/crud.py @@ -1,11 +1,12 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from typing import Any, TypeVar from sqlalchemy.exc import IntegrityError from sqlmodel import SQLModel, select from sqlmodel.ext.asyncio.session import AsyncSession +from sqlmodel.sql.expression import SelectOfScalar ModelT = TypeVar("ModelT", bound=SQLModel) @@ -18,15 +19,19 @@ class MultipleObjectsReturned(LookupError): pass +def _lookup_statement(model: type[ModelT], lookup: Mapping[str, Any]) -> SelectOfScalar[ModelT]: + stmt = select(model) + for key, value in lookup.items(): + stmt = stmt.where(getattr(model, key) == value) + return stmt + + async def get_by_id(session: AsyncSession, model: type[ModelT], obj_id: Any) -> ModelT | None: return await session.get(model, obj_id) async def get(session: AsyncSession, model: type[ModelT], **lookup: Any) -> ModelT: - stmt = select(model) - for key, value in lookup.items(): - stmt = stmt.where(getattr(model, key) == value) - stmt = stmt.limit(2) + stmt = _lookup_statement(model, lookup).limit(2) items = (await session.exec(stmt)).all() if not items: raise DoesNotExist(f"{model.__name__} matching query does not exist.") @@ -38,9 +43,7 @@ async def get(session: AsyncSession, model: type[ModelT], **lookup: Any) -> Mode async def get_one_by(session: AsyncSession, model: type[ModelT], **lookup: Any) -> ModelT | None: - stmt = select(model) - for key, value in lookup.items(): - stmt = stmt.where(getattr(model, key) == value) + stmt = _lookup_statement(model, lookup) return (await session.exec(stmt)).first() @@ -84,6 +87,64 @@ async def delete(session: AsyncSession, obj: ModelT, *, commit: bool = True) -> await session.commit() +async def list_by( + session: AsyncSession, + model: type[ModelT], + *, + order_by: Iterable[Any] = (), + limit: int | None = None, + offset: int | None = None, + **lookup: Any, +) -> list[ModelT]: + stmt = _lookup_statement(model, lookup) + for ordering in order_by: + stmt = stmt.order_by(ordering) + if offset is not None: + stmt = stmt.offset(offset) + if limit is not None: + stmt = stmt.limit(limit) + return list(await session.exec(stmt)) + + +async def exists(session: AsyncSession, model: type[ModelT], **lookup: Any) -> bool: + return (await session.exec(_lookup_statement(model, lookup).limit(1))).first() is not None + + +def apply_updates( + obj: ModelT, + updates: Mapping[str, Any], + *, + exclude_none: bool = False, + allowed_fields: set[str] | None = None, +) -> ModelT: + for key, value in updates.items(): + if allowed_fields is not None and key not in allowed_fields: + continue + if exclude_none and value is None: + continue + setattr(obj, key, value) + return obj + + +async def patch( + session: AsyncSession, + obj: ModelT, + updates: Mapping[str, Any], + *, + exclude_none: bool = False, + allowed_fields: set[str] | None = None, + commit: bool = True, + refresh: bool = True, +) -> ModelT: + apply_updates( + obj, + updates, + exclude_none=exclude_none, + allowed_fields=allowed_fields, + ) + return await save(session, obj, commit=commit, refresh=refresh) + + async def get_or_create( session: AsyncSession, model: type[ModelT], @@ -93,9 +154,7 @@ async def get_or_create( refresh: bool = True, **lookup: Any, ) -> tuple[ModelT, bool]: - stmt = select(model) - for key, value in lookup.items(): - stmt = stmt.where(getattr(model, key) == value) + stmt = _lookup_statement(model, lookup) existing = (await session.exec(stmt)).first() if existing is not None: diff --git a/backend/app/db/queryset.py b/backend/app/db/queryset.py new file mode 100644 index 00000000..b6b31582 --- /dev/null +++ b/backend/app/db/queryset.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Any, Generic, TypeVar + +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession +from sqlmodel.sql.expression import SelectOfScalar + +ModelT = TypeVar("ModelT") + + +@dataclass(frozen=True) +class QuerySet(Generic[ModelT]): + statement: SelectOfScalar[ModelT] + + def filter(self, *criteria: Any) -> QuerySet[ModelT]: + return replace(self, statement=self.statement.where(*criteria)) + + def order_by(self, *ordering: Any) -> QuerySet[ModelT]: + return replace(self, statement=self.statement.order_by(*ordering)) + + def limit(self, value: int) -> QuerySet[ModelT]: + return replace(self, statement=self.statement.limit(value)) + + def offset(self, value: int) -> QuerySet[ModelT]: + return replace(self, statement=self.statement.offset(value)) + + async def all(self, session: AsyncSession) -> list[ModelT]: + return list(await session.exec(self.statement)) + + async def first(self, session: AsyncSession) -> ModelT | None: + return (await session.exec(self.statement)).first() + + async def one_or_none(self, session: AsyncSession) -> ModelT | None: + return (await session.exec(self.statement)).one_or_none() + + async def exists(self, session: AsyncSession) -> bool: + return await self.limit(1).first(session) is not None + + +def qs(model: type[ModelT]) -> QuerySet[ModelT]: + return QuerySet(select(model)) diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 3842171c..0f80aed6 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -5,12 +5,12 @@ from collections.abc import AsyncGenerator from pathlib import Path import anyio +from alembic import command +from alembic.config import Config from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine from sqlmodel import SQLModel from sqlmodel.ext.asyncio.session import AsyncSession -from alembic import command -from alembic.config import Config from app import models # noqa: F401 from app.core.config import settings @@ -51,12 +51,12 @@ def run_migrations() -> None: async def init_db() -> None: if settings.db_auto_migrate: - versions_dir = Path(__file__).resolve().parents[2] / "alembic" / "versions" + versions_dir = Path(__file__).resolve().parents[2] / "migrations" / "versions" if any(versions_dir.glob("*.py")): - logger.info("Running Alembic migrations on startup") + logger.info("Running migrations on startup") await anyio.to_thread.run_sync(run_migrations) return - logger.warning("No Alembic revisions found; falling back to create_all") + logger.warning("No migration revisions found; falling back to create_all") async with async_engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) diff --git a/backend/app/db/sqlmodel_exec.py b/backend/app/db/sqlmodel_exec.py new file mode 100644 index 00000000..1ac22403 --- /dev/null +++ b/backend/app/db/sqlmodel_exec.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from sqlalchemy.sql.base import Executable +from sqlmodel.ext.asyncio.session import AsyncSession + + +async def exec_dml(session: AsyncSession, statement: Executable) -> None: + # SQLModel's AsyncSession typing only overloads exec() for SELECT statements. + await session.exec(statement) # type: ignore[call-overload] diff --git a/backend/app/queries/__init__.py b/backend/app/queries/__init__.py new file mode 100644 index 00000000..9d48db4f --- /dev/null +++ b/backend/app/queries/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/app/queries/organizations.py b/backend/app/queries/organizations.py new file mode 100644 index 00000000..9699825d --- /dev/null +++ b/backend/app/queries/organizations.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from uuid import UUID + +from sqlmodel import col + +from app.db.queryset import QuerySet, qs +from app.models.organization_board_access import OrganizationBoardAccess +from app.models.organization_invites import OrganizationInvite +from app.models.organization_members import OrganizationMember +from app.models.organizations import Organization + + +def organization_by_name(name: str) -> QuerySet[Organization]: + return qs(Organization).filter(col(Organization.name) == name) + + +def member_by_user_and_org(*, user_id: UUID, organization_id: UUID) -> QuerySet[OrganizationMember]: + return qs(OrganizationMember).filter( + col(OrganizationMember.organization_id) == organization_id, + col(OrganizationMember.user_id) == user_id, + ) + + +def first_membership_for_user(user_id: UUID) -> QuerySet[OrganizationMember]: + return ( + qs(OrganizationMember) + .filter(col(OrganizationMember.user_id) == user_id) + .order_by(col(OrganizationMember.created_at).asc()) + ) + + +def pending_invite_by_email(email: str) -> QuerySet[OrganizationInvite]: + return ( + qs(OrganizationInvite) + .filter(col(OrganizationInvite.accepted_at).is_(None)) + .filter(col(OrganizationInvite.invited_email) == email) + .order_by(col(OrganizationInvite.created_at).asc()) + ) + + +def board_access_for_member_and_board( + *, + organization_member_id: UUID, + board_id: UUID, +) -> QuerySet[OrganizationBoardAccess]: + return qs(OrganizationBoardAccess).filter( + col(OrganizationBoardAccess.organization_member_id) == organization_member_id, + col(OrganizationBoardAccess.board_id) == board_id, + ) diff --git a/backend/app/services/organizations.py b/backend/app/services/organizations.py index 8cac8070..dcb752d5 100644 --- a/backend/app/services/organizations.py +++ b/backend/app/services/organizations.py @@ -11,6 +11,7 @@ from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession from app.core.time import utcnow +from app.db.sqlmodel_exec import exec_dml from app.models.boards import Board from app.models.organization_board_access import OrganizationBoardAccess from app.models.organization_invite_board_access import OrganizationInviteBoardAccess @@ -18,6 +19,7 @@ from app.models.organization_invites import OrganizationInvite from app.models.organization_members import OrganizationMember from app.models.organizations import Organization from app.models.users import User +from app.queries import organizations as org_queries from app.schemas.organizations import OrganizationBoardAccessSpec, OrganizationMemberAccessUpdate DEFAULT_ORG_NAME = "Personal" @@ -36,8 +38,7 @@ def is_org_admin(member: OrganizationMember) -> bool: async def get_default_org(session: AsyncSession) -> Organization | None: - statement = select(Organization).where(col(Organization.name) == DEFAULT_ORG_NAME) - return (await session.exec(statement)).first() + return await org_queries.organization_by_name(DEFAULT_ORG_NAME).first(session) async def ensure_default_org(session: AsyncSession) -> Organization: @@ -57,20 +58,14 @@ async def get_member( user_id: UUID, organization_id: UUID, ) -> OrganizationMember | None: - statement = select(OrganizationMember).where( - col(OrganizationMember.organization_id) == organization_id, - col(OrganizationMember.user_id) == user_id, - ) - return (await session.exec(statement)).first() + return await org_queries.member_by_user_and_org( + user_id=user_id, + organization_id=organization_id, + ).first(session) async def get_first_membership(session: AsyncSession, user_id: UUID) -> OrganizationMember | None: - statement = ( - select(OrganizationMember) - .where(col(OrganizationMember.user_id) == user_id) - .order_by(col(OrganizationMember.created_at).asc()) - ) - return (await session.exec(statement)).first() + return await org_queries.first_membership_for_user(user_id).first(session) async def set_active_organization( @@ -124,13 +119,7 @@ async def _find_pending_invite( session: AsyncSession, email: str, ) -> OrganizationInvite | None: - statement = ( - select(OrganizationInvite) - .where(col(OrganizationInvite.accepted_at).is_(None)) - .where(col(OrganizationInvite.invited_email) == email) - .order_by(col(OrganizationInvite.created_at).asc()) - ) - return (await session.exec(statement)).first() + return await org_queries.pending_invite_by_email(email).first(session) async def accept_invite( @@ -241,11 +230,10 @@ async def has_board_access( else: if member_all_boards_read(member): return True - statement = select(OrganizationBoardAccess).where( - col(OrganizationBoardAccess.organization_member_id) == member.id, - col(OrganizationBoardAccess.board_id) == board.id, - ) - access = (await session.exec(statement)).first() + access = await org_queries.board_access_for_member_and_board( + organization_member_id=member.id, + board_id=board.id, + ).first(session) if access is None: return False if write: @@ -330,7 +318,8 @@ async def apply_member_access_update( member.updated_at = now session.add(member) - await session.execute( + await exec_dml( + session, delete(OrganizationBoardAccess).where( col(OrganizationBoardAccess.organization_member_id) == member.id ), @@ -360,7 +349,8 @@ async def apply_invite_board_access( invite: OrganizationInvite, entries: Iterable[OrganizationBoardAccessSpec], ) -> None: - await session.execute( + await exec_dml( + session, delete(OrganizationInviteBoardAccess).where( col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id ), diff --git a/backend/app/services/task_dependencies.py b/backend/app/services/task_dependencies.py index d79c4072..166c60ed 100644 --- a/backend/app/services/task_dependencies.py +++ b/backend/app/services/task_dependencies.py @@ -10,6 +10,7 @@ from sqlalchemy import delete from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession +from app.db.sqlmodel_exec import exec_dml from app.models.task_dependencies import TaskDependency from app.models.tasks import Task @@ -194,10 +195,11 @@ async def replace_task_dependencies( task_id=task_id, depends_on_task_ids=depends_on_task_ids, ) - await session.execute( + await exec_dml( + session, delete(TaskDependency) .where(col(TaskDependency.board_id) == board_id) - .where(col(TaskDependency.task_id) == task_id) + .where(col(TaskDependency.task_id) == task_id), ) for dep_id in normalized: session.add( diff --git a/backend/alembic/env.py b/backend/migrations/env.py similarity index 100% rename from backend/alembic/env.py rename to backend/migrations/env.py diff --git a/backend/alembic/script.py.mako b/backend/migrations/script.py.mako similarity index 100% rename from backend/alembic/script.py.mako rename to backend/migrations/script.py.mako diff --git a/backend/migrations/versions/658dca8f4a11_init.py b/backend/migrations/versions/658dca8f4a11_init.py new file mode 100644 index 00000000..cff2c977 --- /dev/null +++ b/backend/migrations/versions/658dca8f4a11_init.py @@ -0,0 +1,678 @@ +"""init + +Revision ID: 658dca8f4a11 +Revises: +Create Date: 2026-02-09 00:41:55.760624 + +""" + +from __future__ import annotations + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision = "658dca8f4a11" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "organizations", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name", name="uq_organizations_name"), + ) + op.create_index(op.f("ix_organizations_name"), "organizations", ["name"], unique=False) + op.create_table( + "board_groups", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("slug", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_board_groups_organization_id"), "board_groups", ["organization_id"], unique=False + ) + op.create_index(op.f("ix_board_groups_slug"), "board_groups", ["slug"], unique=False) + op.create_table( + "gateways", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("url", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("main_session_key", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("workspace_root", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_gateways_organization_id"), "gateways", ["organization_id"], unique=False + ) + op.create_table( + "users", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("clerk_user_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("preferred_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("pronouns", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("timezone", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("context", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("is_super_admin", sa.Boolean(), nullable=False), + sa.Column("active_organization_id", sa.Uuid(), nullable=True), + sa.ForeignKeyConstraint( + ["active_organization_id"], + ["organizations.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_users_active_organization_id"), "users", ["active_organization_id"], unique=False + ) + op.create_index(op.f("ix_users_clerk_user_id"), "users", ["clerk_user_id"], unique=True) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=False) + op.create_table( + "board_group_memory", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("board_group_id", sa.Uuid(), nullable=False), + sa.Column("content", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("tags", sa.JSON(), nullable=True), + sa.Column("is_chat", sa.Boolean(), nullable=False), + sa.Column("source", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["board_group_id"], + ["board_groups.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_board_group_memory_board_group_id"), + "board_group_memory", + ["board_group_id"], + unique=False, + ) + op.create_index( + op.f("ix_board_group_memory_is_chat"), "board_group_memory", ["is_chat"], unique=False + ) + op.create_table( + "boards", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("slug", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("gateway_id", sa.Uuid(), nullable=True), + sa.Column("board_group_id", sa.Uuid(), nullable=True), + sa.Column("board_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("objective", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("success_metrics", sa.JSON(), nullable=True), + sa.Column("target_date", sa.DateTime(), nullable=True), + sa.Column("goal_confirmed", sa.Boolean(), nullable=False), + sa.Column("goal_source", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["board_group_id"], + ["board_groups.id"], + ), + sa.ForeignKeyConstraint( + ["gateway_id"], + ["gateways.id"], + ), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_boards_board_group_id"), "boards", ["board_group_id"], unique=False) + op.create_index(op.f("ix_boards_board_type"), "boards", ["board_type"], unique=False) + op.create_index(op.f("ix_boards_gateway_id"), "boards", ["gateway_id"], unique=False) + op.create_index(op.f("ix_boards_organization_id"), "boards", ["organization_id"], unique=False) + op.create_index(op.f("ix_boards_slug"), "boards", ["slug"], unique=False) + op.create_table( + "organization_invites", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("invited_email", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("role", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("all_boards_read", sa.Boolean(), nullable=False), + sa.Column("all_boards_write", sa.Boolean(), nullable=False), + sa.Column("created_by_user_id", sa.Uuid(), nullable=True), + sa.Column("accepted_by_user_id", sa.Uuid(), nullable=True), + sa.Column("accepted_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["accepted_by_user_id"], + ["users.id"], + ), + sa.ForeignKeyConstraint( + ["created_by_user_id"], + ["users.id"], + ), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token", name="uq_org_invites_token"), + ) + op.create_index( + op.f("ix_organization_invites_accepted_by_user_id"), + "organization_invites", + ["accepted_by_user_id"], + unique=False, + ) + op.create_index( + op.f("ix_organization_invites_created_by_user_id"), + "organization_invites", + ["created_by_user_id"], + unique=False, + ) + op.create_index( + op.f("ix_organization_invites_invited_email"), + "organization_invites", + ["invited_email"], + unique=False, + ) + op.create_index( + op.f("ix_organization_invites_organization_id"), + "organization_invites", + ["organization_id"], + unique=False, + ) + op.create_index( + op.f("ix_organization_invites_role"), "organization_invites", ["role"], unique=False + ) + op.create_index( + op.f("ix_organization_invites_token"), "organization_invites", ["token"], unique=False + ) + op.create_table( + "organization_members", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("role", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("all_boards_read", sa.Boolean(), nullable=False), + sa.Column("all_boards_write", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("organization_id", "user_id", name="uq_organization_members_org_user"), + ) + op.create_index( + op.f("ix_organization_members_organization_id"), + "organization_members", + ["organization_id"], + unique=False, + ) + op.create_index( + op.f("ix_organization_members_role"), "organization_members", ["role"], unique=False + ) + op.create_index( + op.f("ix_organization_members_user_id"), "organization_members", ["user_id"], unique=False + ) + op.create_table( + "agents", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("board_id", sa.Uuid(), nullable=True), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("openclaw_session_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("agent_token_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("heartbeat_config", sa.JSON(), nullable=True), + sa.Column("identity_profile", sa.JSON(), nullable=True), + sa.Column("identity_template", sa.Text(), nullable=True), + sa.Column("soul_template", sa.Text(), nullable=True), + sa.Column("provision_requested_at", sa.DateTime(), nullable=True), + sa.Column( + "provision_confirm_token_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True + ), + sa.Column("provision_action", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("delete_requested_at", sa.DateTime(), nullable=True), + sa.Column("delete_confirm_token_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("last_seen_at", sa.DateTime(), nullable=True), + sa.Column("is_board_lead", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["board_id"], + ["boards.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_agents_agent_token_hash"), "agents", ["agent_token_hash"], unique=False + ) + op.create_index(op.f("ix_agents_board_id"), "agents", ["board_id"], unique=False) + op.create_index( + op.f("ix_agents_delete_confirm_token_hash"), + "agents", + ["delete_confirm_token_hash"], + unique=False, + ) + op.create_index(op.f("ix_agents_is_board_lead"), "agents", ["is_board_lead"], unique=False) + op.create_index(op.f("ix_agents_name"), "agents", ["name"], unique=False) + op.create_index( + op.f("ix_agents_openclaw_session_id"), "agents", ["openclaw_session_id"], unique=False + ) + op.create_index( + op.f("ix_agents_provision_action"), "agents", ["provision_action"], unique=False + ) + op.create_index( + op.f("ix_agents_provision_confirm_token_hash"), + "agents", + ["provision_confirm_token_hash"], + unique=False, + ) + op.create_index(op.f("ix_agents_status"), "agents", ["status"], unique=False) + op.create_table( + "board_memory", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("board_id", sa.Uuid(), nullable=False), + sa.Column("content", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("tags", sa.JSON(), nullable=True), + sa.Column("is_chat", sa.Boolean(), nullable=False), + sa.Column("source", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["board_id"], + ["boards.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_board_memory_board_id"), "board_memory", ["board_id"], unique=False) + op.create_index(op.f("ix_board_memory_is_chat"), "board_memory", ["is_chat"], unique=False) + op.create_table( + "board_onboarding_sessions", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("board_id", sa.Uuid(), nullable=False), + sa.Column("session_key", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("messages", sa.JSON(), nullable=True), + sa.Column("draft_goal", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["board_id"], + ["boards.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_board_onboarding_sessions_board_id"), + "board_onboarding_sessions", + ["board_id"], + unique=False, + ) + op.create_index( + op.f("ix_board_onboarding_sessions_status"), + "board_onboarding_sessions", + ["status"], + unique=False, + ) + op.create_table( + "organization_board_access", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organization_member_id", sa.Uuid(), nullable=False), + sa.Column("board_id", sa.Uuid(), nullable=False), + sa.Column("can_read", sa.Boolean(), nullable=False), + sa.Column("can_write", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["board_id"], + ["boards.id"], + ), + sa.ForeignKeyConstraint( + ["organization_member_id"], + ["organization_members.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "organization_member_id", "board_id", name="uq_org_board_access_member_board" + ), + ) + op.create_index( + op.f("ix_organization_board_access_board_id"), + "organization_board_access", + ["board_id"], + unique=False, + ) + op.create_index( + op.f("ix_organization_board_access_organization_member_id"), + "organization_board_access", + ["organization_member_id"], + unique=False, + ) + op.create_table( + "organization_invite_board_access", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organization_invite_id", sa.Uuid(), nullable=False), + sa.Column("board_id", sa.Uuid(), nullable=False), + sa.Column("can_read", sa.Boolean(), nullable=False), + sa.Column("can_write", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["board_id"], + ["boards.id"], + ), + sa.ForeignKeyConstraint( + ["organization_invite_id"], + ["organization_invites.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "organization_invite_id", "board_id", name="uq_org_invite_board_access_invite_board" + ), + ) + op.create_index( + op.f("ix_organization_invite_board_access_board_id"), + "organization_invite_board_access", + ["board_id"], + unique=False, + ) + op.create_index( + op.f("ix_organization_invite_board_access_organization_invite_id"), + "organization_invite_board_access", + ["organization_invite_id"], + unique=False, + ) + op.create_table( + "tasks", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("board_id", sa.Uuid(), nullable=True), + sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("priority", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("due_at", sa.DateTime(), nullable=True), + sa.Column("in_progress_at", sa.DateTime(), nullable=True), + sa.Column("created_by_user_id", sa.Uuid(), nullable=True), + sa.Column("assigned_agent_id", sa.Uuid(), nullable=True), + sa.Column("auto_created", sa.Boolean(), nullable=False), + sa.Column("auto_reason", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["assigned_agent_id"], + ["agents.id"], + ), + sa.ForeignKeyConstraint( + ["board_id"], + ["boards.id"], + ), + sa.ForeignKeyConstraint( + ["created_by_user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_tasks_assigned_agent_id"), "tasks", ["assigned_agent_id"], unique=False + ) + op.create_index(op.f("ix_tasks_board_id"), "tasks", ["board_id"], unique=False) + op.create_index( + op.f("ix_tasks_created_by_user_id"), "tasks", ["created_by_user_id"], unique=False + ) + op.create_index(op.f("ix_tasks_priority"), "tasks", ["priority"], unique=False) + op.create_index(op.f("ix_tasks_status"), "tasks", ["status"], unique=False) + op.create_table( + "activity_events", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("event_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("message", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("agent_id", sa.Uuid(), nullable=True), + sa.Column("task_id", sa.Uuid(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["agent_id"], + ["agents.id"], + ), + sa.ForeignKeyConstraint( + ["task_id"], + ["tasks.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_activity_events_agent_id"), "activity_events", ["agent_id"], unique=False + ) + op.create_index( + op.f("ix_activity_events_event_type"), "activity_events", ["event_type"], unique=False + ) + op.create_index( + op.f("ix_activity_events_task_id"), "activity_events", ["task_id"], unique=False + ) + op.create_table( + "approvals", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("board_id", sa.Uuid(), nullable=False), + sa.Column("task_id", sa.Uuid(), nullable=True), + sa.Column("agent_id", sa.Uuid(), nullable=True), + sa.Column("action_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("payload", sa.JSON(), nullable=True), + sa.Column("confidence", sa.Integer(), nullable=False), + sa.Column("rubric_scores", sa.JSON(), nullable=True), + sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("resolved_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["agent_id"], + ["agents.id"], + ), + sa.ForeignKeyConstraint( + ["board_id"], + ["boards.id"], + ), + sa.ForeignKeyConstraint( + ["task_id"], + ["tasks.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_approvals_agent_id"), "approvals", ["agent_id"], unique=False) + op.create_index(op.f("ix_approvals_board_id"), "approvals", ["board_id"], unique=False) + op.create_index(op.f("ix_approvals_status"), "approvals", ["status"], unique=False) + op.create_index(op.f("ix_approvals_task_id"), "approvals", ["task_id"], unique=False) + op.create_table( + "task_dependencies", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("board_id", sa.Uuid(), nullable=False), + sa.Column("task_id", sa.Uuid(), nullable=False), + sa.Column("depends_on_task_id", sa.Uuid(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.CheckConstraint("task_id <> depends_on_task_id", name="ck_task_dependencies_no_self"), + sa.ForeignKeyConstraint( + ["board_id"], + ["boards.id"], + ), + sa.ForeignKeyConstraint( + ["depends_on_task_id"], + ["tasks.id"], + ), + sa.ForeignKeyConstraint( + ["task_id"], + ["tasks.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "task_id", "depends_on_task_id", name="uq_task_dependencies_task_id_depends_on_task_id" + ), + ) + op.create_index( + op.f("ix_task_dependencies_board_id"), "task_dependencies", ["board_id"], unique=False + ) + op.create_index( + op.f("ix_task_dependencies_depends_on_task_id"), + "task_dependencies", + ["depends_on_task_id"], + unique=False, + ) + op.create_index( + op.f("ix_task_dependencies_task_id"), "task_dependencies", ["task_id"], unique=False + ) + op.create_table( + "task_fingerprints", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("board_id", sa.Uuid(), nullable=False), + sa.Column("fingerprint_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("task_id", sa.Uuid(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["board_id"], + ["boards.id"], + ), + sa.ForeignKeyConstraint( + ["task_id"], + ["tasks.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_task_fingerprints_board_id"), "task_fingerprints", ["board_id"], unique=False + ) + op.create_index( + op.f("ix_task_fingerprints_fingerprint_hash"), + "task_fingerprints", + ["fingerprint_hash"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_task_fingerprints_fingerprint_hash"), table_name="task_fingerprints") + op.drop_index(op.f("ix_task_fingerprints_board_id"), table_name="task_fingerprints") + op.drop_table("task_fingerprints") + op.drop_index(op.f("ix_task_dependencies_task_id"), table_name="task_dependencies") + op.drop_index(op.f("ix_task_dependencies_depends_on_task_id"), table_name="task_dependencies") + op.drop_index(op.f("ix_task_dependencies_board_id"), table_name="task_dependencies") + op.drop_table("task_dependencies") + op.drop_index(op.f("ix_approvals_task_id"), table_name="approvals") + op.drop_index(op.f("ix_approvals_status"), table_name="approvals") + op.drop_index(op.f("ix_approvals_board_id"), table_name="approvals") + op.drop_index(op.f("ix_approvals_agent_id"), table_name="approvals") + op.drop_table("approvals") + op.drop_index(op.f("ix_activity_events_task_id"), table_name="activity_events") + op.drop_index(op.f("ix_activity_events_event_type"), table_name="activity_events") + op.drop_index(op.f("ix_activity_events_agent_id"), table_name="activity_events") + op.drop_table("activity_events") + op.drop_index(op.f("ix_tasks_status"), table_name="tasks") + op.drop_index(op.f("ix_tasks_priority"), table_name="tasks") + op.drop_index(op.f("ix_tasks_created_by_user_id"), table_name="tasks") + op.drop_index(op.f("ix_tasks_board_id"), table_name="tasks") + op.drop_index(op.f("ix_tasks_assigned_agent_id"), table_name="tasks") + op.drop_table("tasks") + op.drop_index( + op.f("ix_organization_invite_board_access_organization_invite_id"), + table_name="organization_invite_board_access", + ) + op.drop_index( + op.f("ix_organization_invite_board_access_board_id"), + table_name="organization_invite_board_access", + ) + op.drop_table("organization_invite_board_access") + op.drop_index( + op.f("ix_organization_board_access_organization_member_id"), + table_name="organization_board_access", + ) + op.drop_index( + op.f("ix_organization_board_access_board_id"), table_name="organization_board_access" + ) + op.drop_table("organization_board_access") + op.drop_index( + op.f("ix_board_onboarding_sessions_status"), table_name="board_onboarding_sessions" + ) + op.drop_index( + op.f("ix_board_onboarding_sessions_board_id"), table_name="board_onboarding_sessions" + ) + op.drop_table("board_onboarding_sessions") + op.drop_index(op.f("ix_board_memory_is_chat"), table_name="board_memory") + op.drop_index(op.f("ix_board_memory_board_id"), table_name="board_memory") + op.drop_table("board_memory") + op.drop_index(op.f("ix_agents_status"), table_name="agents") + op.drop_index(op.f("ix_agents_provision_confirm_token_hash"), table_name="agents") + op.drop_index(op.f("ix_agents_provision_action"), table_name="agents") + op.drop_index(op.f("ix_agents_openclaw_session_id"), table_name="agents") + op.drop_index(op.f("ix_agents_name"), table_name="agents") + op.drop_index(op.f("ix_agents_is_board_lead"), table_name="agents") + op.drop_index(op.f("ix_agents_delete_confirm_token_hash"), table_name="agents") + op.drop_index(op.f("ix_agents_board_id"), table_name="agents") + op.drop_index(op.f("ix_agents_agent_token_hash"), table_name="agents") + op.drop_table("agents") + op.drop_index(op.f("ix_organization_members_user_id"), table_name="organization_members") + op.drop_index(op.f("ix_organization_members_role"), table_name="organization_members") + op.drop_index( + op.f("ix_organization_members_organization_id"), table_name="organization_members" + ) + op.drop_table("organization_members") + op.drop_index(op.f("ix_organization_invites_token"), table_name="organization_invites") + op.drop_index(op.f("ix_organization_invites_role"), table_name="organization_invites") + op.drop_index( + op.f("ix_organization_invites_organization_id"), table_name="organization_invites" + ) + op.drop_index(op.f("ix_organization_invites_invited_email"), table_name="organization_invites") + op.drop_index( + op.f("ix_organization_invites_created_by_user_id"), table_name="organization_invites" + ) + op.drop_index( + op.f("ix_organization_invites_accepted_by_user_id"), table_name="organization_invites" + ) + op.drop_table("organization_invites") + op.drop_index(op.f("ix_boards_slug"), table_name="boards") + op.drop_index(op.f("ix_boards_organization_id"), table_name="boards") + op.drop_index(op.f("ix_boards_gateway_id"), table_name="boards") + op.drop_index(op.f("ix_boards_board_type"), table_name="boards") + op.drop_index(op.f("ix_boards_board_group_id"), table_name="boards") + op.drop_table("boards") + op.drop_index(op.f("ix_board_group_memory_is_chat"), table_name="board_group_memory") + op.drop_index(op.f("ix_board_group_memory_board_group_id"), table_name="board_group_memory") + op.drop_table("board_group_memory") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_index(op.f("ix_users_clerk_user_id"), table_name="users") + op.drop_index(op.f("ix_users_active_organization_id"), table_name="users") + op.drop_table("users") + op.drop_index(op.f("ix_gateways_organization_id"), table_name="gateways") + op.drop_table("gateways") + op.drop_index(op.f("ix_board_groups_slug"), table_name="board_groups") + op.drop_index(op.f("ix_board_groups_organization_id"), table_name="board_groups") + op.drop_table("board_groups") + op.drop_index(op.f("ix_organizations_name"), table_name="organizations") + op.drop_table("organizations") + # ### end Alembic commands ### diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d42edc28..fca22205 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,12 +1,12 @@ [tool.black] line-length = 100 target-version = ["py312"] -extend-exclude = '(\.venv|alembic/versions)' +extend-exclude = '(\.venv|migrations/versions)' [tool.isort] profile = "black" line_length = 100 -skip = [".venv", "alembic/versions"] +skip = [".venv", "migrations/versions"] [project] name = "openclaw-agency-backend" version = "0.1.0" @@ -59,7 +59,7 @@ asyncio_default_fixture_loop_scope = "function" branch = true source = ["app"] # Migrations are generated artifacts; testing them doesn't add coverage signal. -omit = ["alembic/versions/*"] +omit = ["migrations/versions/*"] [tool.coverage.report] show_missing = true diff --git a/backend/tests/test_board_groups_delete.py b/backend/tests/test_board_groups_delete.py index d904716e..d810c1ee 100644 --- a/backend/tests/test_board_groups_delete.py +++ b/backend/tests/test_board_groups_delete.py @@ -15,6 +15,9 @@ class _FakeSession: executed: list[Any] = field(default_factory=list) committed: int = 0 + async def exec(self, statement: Any) -> None: + self.executed.append(statement) + async def execute(self, statement: Any) -> None: self.executed.append(statement) diff --git a/backend/tests/test_boards_delete.py b/backend/tests/test_boards_delete.py index ffa21f35..fb621eee 100644 --- a/backend/tests/test_boards_delete.py +++ b/backend/tests/test_boards_delete.py @@ -17,7 +17,11 @@ class _FakeSession: deleted: list[Any] = field(default_factory=list) committed: int = 0 - async def exec(self, _statement: Any) -> Any: + async def exec(self, statement: Any) -> Any: + is_dml = statement.__class__.__name__ in {"Delete", "Update", "Insert"} + if is_dml: + self.executed.append(statement) + return None if not self.exec_results: raise AssertionError("No more exec_results left for session.exec") return self.exec_results.pop(0) diff --git a/backend/tests/test_organizations_delete_api.py b/backend/tests/test_organizations_delete_api.py index 03b96d2b..34bfeb05 100644 --- a/backend/tests/test_organizations_delete_api.py +++ b/backend/tests/test_organizations_delete_api.py @@ -16,6 +16,9 @@ class _FakeSession: executed: list[Any] = field(default_factory=list) committed: int = 0 + async def exec(self, statement: Any) -> None: + self.executed.append(statement) + async def execute(self, statement: Any) -> None: self.executed.append(statement) diff --git a/backend/tests/test_organizations_member_remove_api.py b/backend/tests/test_organizations_member_remove_api.py index f542f16c..44780206 100644 --- a/backend/tests/test_organizations_member_remove_api.py +++ b/backend/tests/test_organizations_member_remove_api.py @@ -36,6 +36,10 @@ class _FakeSession: committed: int = 0 async def exec(self, _statement: Any) -> Any: + is_dml = _statement.__class__.__name__ in {"Delete", "Update", "Insert"} + if is_dml: + self.executed.append(_statement) + return None if not self.exec_results: raise AssertionError("No more exec_results left for session.exec") return self.exec_results.pop(0) diff --git a/backend/tests/test_organizations_service.py b/backend/tests/test_organizations_service.py index 13e10adf..20b67db1 100644 --- a/backend/tests/test_organizations_service.py +++ b/backend/tests/test_organizations_service.py @@ -51,6 +51,10 @@ class _FakeSession: refreshed: list[Any] = field(default_factory=list) async def exec(self, _statement: Any) -> Any: + is_dml = _statement.__class__.__name__ in {"Delete", "Update", "Insert"} + if is_dml: + self.executed.append(_statement) + return None if not self.exec_results: raise AssertionError("No more exec_results left for session.exec") return self.exec_results.pop(0) diff --git a/backend/tests/test_task_dependencies.py b/backend/tests/test_task_dependencies.py index bb3d1a82..d86afff7 100644 --- a/backend/tests/test_task_dependencies.py +++ b/backend/tests/test_task_dependencies.py @@ -68,6 +68,10 @@ class _FakeSession: added: list[object] = field(default_factory=list) async def exec(self, _query): + is_dml = _query.__class__.__name__ in {"Delete", "Update", "Insert"} + if is_dml: + self.executed.append(_query) + return None if not self.exec_results: raise AssertionError("No more exec_results left for session.exec") return self.exec_results.pop(0) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index b461a9ca..e3311868 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -49,7 +49,7 @@ flowchart LR ### Data stores - **Postgres**: persistence for boards/tasks/agents/approvals/etc. - Models: `backend/app/models/*` - - Migrations: `backend/alembic/*` + - Migrations: `backend/migrations/*` - **Redis**: used for background primitives. - RQ helper: `backend/app/workers/queue.py`