Transition from team-based to account-based multi-tenancy (Free/Pro/Team). Migrations 016-020 create accounts, subscriptions, plan_limits, and account_invites tables, then migrate existing users and content FKs. New models: Account, Subscription, PlanLimits, AccountInvite. Updated models add account_id alongside existing team_id (coexistence for safe two-PR deployment). Permissions and deps refactored for account_role instead of is_team_admin. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
188 lines
7.0 KiB
Python
188 lines
7.0 KiB
Python
"""migrate existing users and teams to accounts
|
|
|
|
Revision ID: 018
|
|
Revises: 017
|
|
Create Date: 2026-02-07
|
|
|
|
This is the most critical migration. It creates a _team_account_mapping table
|
|
for deterministic cross-migration lookups, then migrates all users to accounts.
|
|
|
|
Three paths:
|
|
A) Teams with users → Account with deterministic owner
|
|
B) Teams with zero users → Account with owner_id=NULL, subscription status='orphaned'
|
|
C) Users without a team → Personal account, user is owner
|
|
"""
|
|
import secrets
|
|
import string
|
|
from typing import Sequence, Union
|
|
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|
|
|
|
|
# revision identifiers, used by Alembic.
|
|
revision: str = '018'
|
|
down_revision: Union[str, None] = '017'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
# Characters for display codes — exclude confusing chars
|
|
DISPLAY_CODE_CHARS = string.ascii_uppercase + string.digits
|
|
DISPLAY_CODE_CHARS = DISPLAY_CODE_CHARS.replace('0', '').replace('O', '').replace('I', '').replace('1', '').replace('L', '')
|
|
|
|
|
|
def _generate_display_code(existing_codes: set) -> str:
|
|
"""Generate a unique 8-character display code."""
|
|
for _ in range(100):
|
|
code = ''.join(secrets.choice(DISPLAY_CODE_CHARS) for _ in range(8))
|
|
if code not in existing_codes:
|
|
existing_codes.add(code)
|
|
return code
|
|
raise RuntimeError("Failed to generate unique display code after 100 attempts")
|
|
|
|
|
|
def upgrade() -> None:
|
|
conn = op.get_bind()
|
|
|
|
# Create mapping table for deterministic cross-migration lookups
|
|
op.create_table(
|
|
'_team_account_mapping',
|
|
sa.Column('team_id', UUID(as_uuid=True), nullable=False),
|
|
sa.Column('account_id', UUID(as_uuid=True), nullable=False),
|
|
sa.Column('owner_user_id', UUID(as_uuid=True), nullable=True),
|
|
sa.PrimaryKeyConstraint('team_id'),
|
|
)
|
|
|
|
existing_codes: set = set()
|
|
|
|
# --- Path A & B: Process all teams ---
|
|
teams = conn.execute(sa.text("SELECT id, name FROM teams")).fetchall()
|
|
|
|
for team in teams:
|
|
team_id = team[0]
|
|
team_name = team[1]
|
|
display_code = _generate_display_code(existing_codes)
|
|
|
|
# Find deterministic owner: team admin first, then earliest user
|
|
owner_row = conn.execute(sa.text("""
|
|
SELECT id FROM users
|
|
WHERE team_id = :tid
|
|
ORDER BY is_team_admin DESC, created_at ASC, id ASC
|
|
LIMIT 1
|
|
"""), {"tid": team_id}).fetchone()
|
|
|
|
owner_user_id = owner_row[0] if owner_row else None
|
|
|
|
# Create account
|
|
conn.execute(sa.text("""
|
|
INSERT INTO accounts (id, name, display_code, owner_id, created_at, updated_at)
|
|
VALUES (gen_random_uuid(), :name, :code, :owner_id, NOW(), NOW())
|
|
"""), {"name": team_name, "code": display_code, "owner_id": owner_user_id})
|
|
|
|
# Get the account we just created
|
|
account_row = conn.execute(sa.text(
|
|
"SELECT id FROM accounts WHERE display_code = :code"
|
|
), {"code": display_code}).fetchone()
|
|
account_id = account_row[0]
|
|
|
|
# Insert mapping
|
|
conn.execute(sa.text("""
|
|
INSERT INTO _team_account_mapping (team_id, account_id, owner_user_id)
|
|
VALUES (:tid, :aid, :uid)
|
|
"""), {"tid": team_id, "aid": account_id, "uid": owner_user_id})
|
|
|
|
if owner_user_id is not None:
|
|
# Path A: Team with users
|
|
# Create active subscription
|
|
conn.execute(sa.text("""
|
|
INSERT INTO subscriptions (id, account_id, plan, status, created_at, updated_at)
|
|
VALUES (gen_random_uuid(), :aid, 'free', 'active', NOW(), NOW())
|
|
"""), {"aid": account_id})
|
|
|
|
# Update all users in this team
|
|
# Team admins become owners, others keep their role
|
|
conn.execute(sa.text("""
|
|
UPDATE users SET
|
|
account_id = :aid,
|
|
account_role = CASE
|
|
WHEN is_team_admin = true THEN 'owner'
|
|
ELSE role
|
|
END
|
|
WHERE team_id = :tid
|
|
"""), {"aid": account_id, "tid": team_id})
|
|
else:
|
|
# Path B: Team with zero users (orphan)
|
|
conn.execute(sa.text("""
|
|
INSERT INTO subscriptions (id, account_id, plan, status, created_at, updated_at)
|
|
VALUES (gen_random_uuid(), :aid, 'free', 'orphaned', NOW(), NOW())
|
|
"""), {"aid": account_id})
|
|
|
|
# --- Path C: Users without a team ---
|
|
teamless_users = conn.execute(sa.text(
|
|
"SELECT id, name FROM users WHERE team_id IS NULL AND account_id IS NULL"
|
|
)).fetchall()
|
|
|
|
for user in teamless_users:
|
|
user_id = user[0]
|
|
user_name = user[1]
|
|
display_code = _generate_display_code(existing_codes)
|
|
|
|
# Create personal account (owner_id set to NULL initially)
|
|
conn.execute(sa.text("""
|
|
INSERT INTO accounts (id, name, display_code, owner_id, created_at, updated_at)
|
|
VALUES (gen_random_uuid(), :name, :code, NULL, NOW(), NOW())
|
|
"""), {"name": f"{user_name}'s Account", "code": display_code})
|
|
|
|
account_row = conn.execute(sa.text(
|
|
"SELECT id FROM accounts WHERE display_code = :code"
|
|
), {"code": display_code}).fetchone()
|
|
account_id = account_row[0]
|
|
|
|
# Update user
|
|
conn.execute(sa.text("""
|
|
UPDATE users SET account_id = :aid, account_role = 'owner'
|
|
WHERE id = :uid
|
|
"""), {"aid": account_id, "uid": user_id})
|
|
|
|
# Set owner
|
|
conn.execute(sa.text("""
|
|
UPDATE accounts SET owner_id = :uid WHERE id = :aid
|
|
"""), {"uid": user_id, "aid": account_id})
|
|
|
|
# Create free subscription
|
|
conn.execute(sa.text("""
|
|
INSERT INTO subscriptions (id, account_id, plan, status, created_at, updated_at)
|
|
VALUES (gen_random_uuid(), :aid, 'free', 'active', NOW(), NOW())
|
|
"""), {"aid": account_id})
|
|
|
|
# --- Validation ---
|
|
orphaned_users = conn.execute(sa.text(
|
|
"SELECT COUNT(*) FROM users WHERE account_id IS NULL"
|
|
)).scalar()
|
|
if orphaned_users > 0:
|
|
raise RuntimeError(
|
|
f"Migration 018 failed validation: {orphaned_users} users still have NULL account_id"
|
|
)
|
|
|
|
team_count = conn.execute(sa.text("SELECT COUNT(*) FROM teams")).scalar()
|
|
mapping_count = conn.execute(sa.text("SELECT COUNT(*) FROM _team_account_mapping")).scalar()
|
|
if mapping_count != team_count:
|
|
raise RuntimeError(
|
|
f"Migration 018 failed: mapping count ({mapping_count}) != team count ({team_count})"
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
conn = op.get_bind()
|
|
|
|
# Clear account data from users
|
|
conn.execute(sa.text("UPDATE users SET account_id = NULL, account_role = NULL"))
|
|
|
|
# Delete all subscriptions and accounts created by this migration
|
|
conn.execute(sa.text("DELETE FROM subscriptions"))
|
|
conn.execute(sa.text("DELETE FROM accounts"))
|
|
|
|
# Drop mapping table
|
|
op.drop_table('_team_account_mapping')
|