Newer
Older
gnexus-tasks / backend / alembic / versions / f9a0b1c2d3e4_mcp_users_multitenancy.py
"""Мультиюзерность: users, mcp_tokens, user_id на таблицах, per-user настройки.

- users: профиль из SSO, upsert при логине; FK-цель для user_id.
- user_id (nullable) на 7 таблицах: существующие строки остаются бесхозными
  (NULL) и приписываются пользователю при первом логине (claim, ТЗ 1.2).
- Уникальности имени проекта/тега становятся per-user (user_id, name).
- uq_coin_daily/uq_coin_level становятся per-user.
- app_settings → app_settings_global (источник claim), новая app_settings
  с PK (user_id, key).

Тесты создают схему через Base.metadata.create_all — эта миграция только
для Postgres.

Revision ID: f9a0b1c2d3e4
Revises: e7f8a9b0c1d2
Create Date: 2026-09-22
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

revision: str = "f9a0b1c2d3e4"
down_revision: str | None = "e7f8a9b0c1d2"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None

USER_TABLES = (
    "projects",
    "tasks",
    "tags",
    "documents",
    "xp_events",
    "coin_events",
    "garden_items",
)


def upgrade() -> None:
    # 1. Пользователи и MCP-токены
    op.create_table(
        "users",
        sa.Column("id", sa.String(64), primary_key=True),
        sa.Column("email", sa.String(255), nullable=False, server_default=""),
        sa.Column("avatar_url", sa.String(500), nullable=True),
        sa.Column("locale", sa.String(16), nullable=True),
        sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
    )
    op.create_table(
        "mcp_tokens",
        sa.Column("id", sa.Integer(), primary_key=True),
        sa.Column("user_id", sa.String(64), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
        sa.Column("token_hash", sa.String(64), nullable=False, unique=True),
        sa.Column("label", sa.String(100), nullable=False, server_default=""),
        sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
    )

    # 2. user_id на таблицы данных (nullable — claim при первом логине)
    for table in USER_TABLES:
        op.add_column(table, sa.Column("user_id", sa.String(64), nullable=True))
        op.create_index(f"ix_{table}_user_id", table, ["user_id"])
        op.create_foreign_key(
            f"fk_{table}_user_id_users", table, "users", ["user_id"], ["id"]
        )

    # 3. Имена проектов/тегов — уникальны в пределах пользователя
    op.drop_constraint("projects_name_key", "projects", type_="unique")
    op.drop_constraint("tags_name_key", "tags", type_="unique")
    op.create_index("uq_projects_user_name", "projects", ["user_id", "name"], unique=True)
    op.create_index("uq_tags_user_name", "tags", ["user_id", "name"], unique=True)

    # 4. Дейли/уровень — награды per-user
    op.drop_index("uq_coin_daily", table_name="coin_events")
    op.drop_index("uq_coin_level", table_name="coin_events")
    op.create_index(
        "uq_coin_daily",
        "coin_events",
        ["user_id", "source", "day"],
        unique=True,
        postgresql_where=sa.text("source = 'daily'"),
    )
    op.create_index(
        "uq_coin_level",
        "coin_events",
        ["user_id", "level"],
        unique=True,
        postgresql_where=sa.text("source = 'level' AND level IS NOT NULL"),
    )

    # 5. Настройки: глобальные → источник claim, новые — per-user
    op.rename_table("app_settings", "app_settings_global")
    op.create_table(
        "app_settings",
        sa.Column("user_id", sa.String(64), primary_key=True),
        sa.Column("key", sa.String(100), primary_key=True),
        sa.Column("value", sa.String(200), nullable=False),
    )


def downgrade() -> None:
    # Данные per-user настроек теряются (остаются только глобальные)
    op.drop_table("app_settings")
    op.rename_table("app_settings_global", "app_settings")
    op.drop_index("uq_coin_level", table_name="coin_events")
    op.drop_index("uq_coin_daily", table_name="coin_events")
    op.create_index(
        "uq_coin_daily",
        "coin_events",
        ["source", "day"],
        unique=True,
        postgresql_where=sa.text("source = 'daily'"),
    )
    op.create_index(
        "uq_coin_level",
        "coin_events",
        ["level"],
        unique=True,
        postgresql_where=sa.text("source = 'level' AND level IS NOT NULL"),
    )
    op.create_unique_constraint("projects_name_key", "projects", ["name"])
    op.create_unique_constraint("tags_name_key", "tags", ["name"])
    op.drop_index("uq_tags_user_name", table_name="tags")
    op.drop_index("uq_projects_user_name", table_name="projects")
    for table in USER_TABLES:
        op.drop_constraint(f"fk_{table}_user_id_users", table, type_="foreignkey")
        op.drop_index(f"ix_{table}_user_id", table_name=table)
        op.drop_column(table, "user_id")
    op.drop_table("mcp_tokens")
    op.drop_table("users")