"""Add GEO lifecycle tables Revision ID: d4f6g8h0ab23 Revises: c3d5e7f9ab12 Create Date: 2026-05-23 10:00:00.000000 """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision: str = 'd4f6g8h0ab23' down_revision: Union[str, Sequence[str], None] = 'c3d5e7f9ab12' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Add organizations, lifecycle, agent, content, platform_rules, brand_knowledge, keywords tables and modify users.""" # 1. organizations table (must be created first as other tables reference it) op.create_table( 'organizations', sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False), sa.Column('name', sa.String(100), nullable=False), sa.Column('slug', sa.String(50), nullable=False), sa.Column('description', sa.Text(), nullable=True), sa.Column('logo_url', sa.String(500), nullable=True), sa.Column('plan', sa.String(20), server_default='free', nullable=False), sa.Column('max_members', sa.Integer(), server_default='5', nullable=False), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('slug') ) op.create_index('idx_organizations_slug', 'organizations', ['slug']) # 2. Modify users table - add organization_id and role op.add_column('users', sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=True)) op.add_column('users', sa.Column('role', sa.String(20), server_default='owner', nullable=False)) op.create_foreign_key('fk_users_organization_id', 'users', 'organizations', ['organization_id'], ['id'], ondelete='SET NULL') op.create_index('idx_users_organization_id', 'users', ['organization_id']) # 3. org_members table op.create_table( 'org_members', sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False), sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('role', sa.String(20), server_default='viewer', nullable=False), sa.Column('joined_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.Column('invited_by', postgresql.UUID(as_uuid=True), nullable=True), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE') ) op.create_index('idx_org_members_organization_id', 'org_members', ['organization_id']) op.create_index('idx_org_members_user_id', 'org_members', ['user_id']) op.create_index('idx_org_members_org_user', 'org_members', ['organization_id', 'user_id'], unique=True) # 4. lifecycle_projects table op.create_table( 'lifecycle_projects', sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False), sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('brand_name', sa.String(100), nullable=False), sa.Column('brand_aliases', postgresql.JSONB(), server_default='[]', nullable=False), sa.Column('current_stage', sa.Integer(), server_default='1', nullable=False), sa.Column('status', sa.String(20), server_default='active', nullable=False), sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='SET NULL') ) op.create_index('idx_lifecycle_projects_organization_id', 'lifecycle_projects', ['organization_id']) op.create_index('idx_lifecycle_projects_status', 'lifecycle_projects', ['status']) op.create_index('idx_lifecycle_projects_brand_name', 'lifecycle_projects', ['brand_name']) # 5. project_stages table op.create_table( 'project_stages', sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False), sa.Column('project_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('stage_number', sa.Integer(), nullable=False), sa.Column('status', sa.String(20), server_default='pending', nullable=False), sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), sa.Column('notes', sa.Text(), nullable=True), sa.Column('metrics', postgresql.JSONB(), nullable=True), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['project_id'], ['lifecycle_projects.id'], ondelete='CASCADE') ) op.create_index('idx_project_stages_project_id', 'project_stages', ['project_id']) op.create_index('idx_project_stages_status', 'project_stages', ['status']) op.create_index('idx_project_stages_project_stage', 'project_stages', ['project_id', 'stage_number'], unique=True) # 6. agent_registry table op.create_table( 'agent_registry', sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False), sa.Column('name', sa.String(50), nullable=False), sa.Column('display_name', sa.String(100), nullable=True), sa.Column('agent_type', sa.String(50), nullable=False), sa.Column('description', sa.Text(), nullable=True), sa.Column('version', sa.String(20), nullable=True), sa.Column('endpoint', sa.String(500), nullable=True), sa.Column('status', sa.String(20), server_default='offline', nullable=False), sa.Column('capabilities', postgresql.JSONB(), nullable=True), sa.Column('last_heartbeat', sa.DateTime(timezone=True), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_index('idx_agent_registry_name', 'agent_registry', ['name']) op.create_index('idx_agent_registry_agent_type', 'agent_registry', ['agent_type']) op.create_index('idx_agent_registry_status', 'agent_registry', ['status']) # 7. agent_configs table op.create_table( 'agent_configs', sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False), sa.Column('agent_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('config_key', sa.String(100), nullable=False), sa.Column('config_value', postgresql.JSONB(), nullable=False), sa.Column('description', sa.String(500), nullable=True), sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.Column('updated_by', postgresql.UUID(as_uuid=True), nullable=True), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['agent_id'], ['agent_registry.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['updated_by'], ['users.id'], ondelete='SET NULL') ) op.create_index('idx_agent_configs_agent_id', 'agent_configs', ['agent_id']) op.create_index('idx_agent_configs_agent_key', 'agent_configs', ['agent_id', 'config_key'], unique=True) # 8. agent_tasks table op.create_table( 'agent_tasks', sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False), sa.Column('agent_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('task_type', sa.String(50), nullable=False), sa.Column('status', sa.String(20), server_default='pending', nullable=False), sa.Column('priority', sa.Integer(), server_default='0', nullable=False), sa.Column('input_data', postgresql.JSONB(), nullable=True), sa.Column('output_data', postgresql.JSONB(), nullable=True), sa.Column('error_message', sa.Text(), nullable=True), sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=True), sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('project_id', postgresql.UUID(as_uuid=True), nullable=True), sa.Column('scheduled_at', sa.DateTime(timezone=True), nullable=True), sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['agent_id'], ['agent_registry.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='SET NULL'), sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['project_id'], ['lifecycle_projects.id'], ondelete='SET NULL') ) op.create_index('idx_agent_tasks_agent_id', 'agent_tasks', ['agent_id']) op.create_index('idx_agent_tasks_status', 'agent_tasks', ['status']) op.create_index('idx_agent_tasks_organization_id', 'agent_tasks', ['organization_id']) op.create_index('idx_agent_tasks_project_id', 'agent_tasks', ['project_id']) op.create_index('idx_agent_tasks_created_by', 'agent_tasks', ['created_by']) op.create_index('idx_agent_tasks_task_type', 'agent_tasks', ['task_type']) # 9. agent_task_logs table op.create_table( 'agent_task_logs', sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False), sa.Column('task_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('agent_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('log_level', sa.String(10), nullable=False), sa.Column('message', sa.Text(), nullable=False), sa.Column('metadata', postgresql.JSONB(), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['task_id'], ['agent_tasks.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['agent_id'], ['agent_registry.id'], ondelete='CASCADE') ) op.create_index('idx_agent_task_logs_task_id', 'agent_task_logs', ['task_id']) op.create_index('idx_agent_task_logs_agent_id', 'agent_task_logs', ['agent_id']) op.create_index('idx_agent_task_logs_created_at', 'agent_task_logs', ['created_at']) # 10. contents table op.create_table( 'contents', sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False), sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('project_id', postgresql.UUID(as_uuid=True), nullable=True), sa.Column('title', sa.String(500), nullable=False), sa.Column('content_type', sa.String(50), nullable=False), sa.Column('body', sa.Text(), nullable=True), sa.Column('status', sa.String(20), server_default='draft', nullable=False), sa.Column('target_platforms', postgresql.JSONB(), nullable=True), sa.Column('keywords', postgresql.JSONB(), nullable=True), sa.Column('metadata', postgresql.JSONB(), nullable=True), sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=True), sa.Column('current_version', sa.Integer(), server_default='1', nullable=False), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['project_id'], ['lifecycle_projects.id'], ondelete='SET NULL'), sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='SET NULL') ) op.create_index('idx_contents_organization_id', 'contents', ['organization_id']) op.create_index('idx_contents_project_id', 'contents', ['project_id']) op.create_index('idx_contents_status', 'contents', ['status']) op.create_index('idx_contents_content_type', 'contents', ['content_type']) op.create_index('idx_contents_created_by', 'contents', ['created_by']) # 11. content_versions table op.create_table( 'content_versions', sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False), sa.Column('content_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('version_number', sa.Integer(), nullable=False), sa.Column('title', sa.String(500), nullable=True), sa.Column('body', sa.Text(), nullable=True), sa.Column('change_summary', sa.String(500), nullable=True), sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['content_id'], ['contents.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='SET NULL') ) op.create_index('idx_content_versions_content_id', 'content_versions', ['content_id']) op.create_index('idx_content_versions_content_version', 'content_versions', ['content_id', 'version_number'], unique=True) # 12. content_reviews table op.create_table( 'content_reviews', sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False), sa.Column('content_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('reviewer_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('status', sa.String(20), nullable=False), sa.Column('comments', sa.Text(), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['content_id'], ['contents.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['reviewer_id'], ['users.id'], ondelete='CASCADE') ) op.create_index('idx_content_reviews_content_id', 'content_reviews', ['content_id']) op.create_index('idx_content_reviews_reviewer_id', 'content_reviews', ['reviewer_id']) # 13. platform_rules table op.create_table( 'platform_rules', sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False), sa.Column('platform', sa.String(50), nullable=False), sa.Column('rule_category', sa.String(50), nullable=False), sa.Column('rule_name', sa.String(200), nullable=False), sa.Column('description', sa.Text(), nullable=True), sa.Column('check_criteria', postgresql.JSONB(), nullable=True), sa.Column('severity', sa.String(20), nullable=False), sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.PrimaryKeyConstraint('id') ) op.create_index('idx_platform_rules_platform', 'platform_rules', ['platform']) op.create_index('idx_platform_rules_rule_category', 'platform_rules', ['rule_category']) op.create_index('idx_platform_rules_is_active', 'platform_rules', ['is_active']) op.create_index('idx_platform_rules_platform_category', 'platform_rules', ['platform', 'rule_category']) # 14. brand_knowledge table op.create_table( 'brand_knowledge', sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False), sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('category', sa.String(50), nullable=False), sa.Column('title', sa.String(200), nullable=False), sa.Column('content', sa.Text(), nullable=False), sa.Column('metadata', postgresql.JSONB(), nullable=True), sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='SET NULL') ) op.create_index('idx_brand_knowledge_organization_id', 'brand_knowledge', ['organization_id']) op.create_index('idx_brand_knowledge_category', 'brand_knowledge', ['category']) op.create_index('idx_brand_knowledge_is_active', 'brand_knowledge', ['is_active']) # 15. keywords table op.create_table( 'keywords', sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False), sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('project_id', postgresql.UUID(as_uuid=True), nullable=True), sa.Column('keyword', sa.String(200), nullable=False), sa.Column('category', sa.String(50), nullable=False), sa.Column('priority', sa.Integer(), server_default='0', nullable=False), sa.Column('search_volume', sa.Integer(), nullable=True), sa.Column('competition_level', sa.String(20), nullable=True), sa.Column('status', sa.String(20), server_default='active', nullable=False), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['project_id'], ['lifecycle_projects.id'], ondelete='SET NULL') ) op.create_index('idx_keywords_organization_id', 'keywords', ['organization_id']) op.create_index('idx_keywords_project_id', 'keywords', ['project_id']) op.create_index('idx_keywords_category', 'keywords', ['category']) op.create_index('idx_keywords_status', 'keywords', ['status']) op.create_index('idx_keywords_keyword', 'keywords', ['keyword']) def downgrade() -> None: """Remove all GEO lifecycle tables and revert users table changes.""" # Drop tables in reverse order (respecting foreign key dependencies) op.drop_index('idx_keywords_keyword', table_name='keywords') op.drop_index('idx_keywords_status', table_name='keywords') op.drop_index('idx_keywords_category', table_name='keywords') op.drop_index('idx_keywords_project_id', table_name='keywords') op.drop_index('idx_keywords_organization_id', table_name='keywords') op.drop_table('keywords') op.drop_index('idx_brand_knowledge_is_active', table_name='brand_knowledge') op.drop_index('idx_brand_knowledge_category', table_name='brand_knowledge') op.drop_index('idx_brand_knowledge_organization_id', table_name='brand_knowledge') op.drop_table('brand_knowledge') op.drop_index('idx_platform_rules_platform_category', table_name='platform_rules') op.drop_index('idx_platform_rules_is_active', table_name='platform_rules') op.drop_index('idx_platform_rules_rule_category', table_name='platform_rules') op.drop_index('idx_platform_rules_platform', table_name='platform_rules') op.drop_table('platform_rules') op.drop_index('idx_content_reviews_reviewer_id', table_name='content_reviews') op.drop_index('idx_content_reviews_content_id', table_name='content_reviews') op.drop_table('content_reviews') op.drop_index('idx_content_versions_content_version', table_name='content_versions') op.drop_index('idx_content_versions_content_id', table_name='content_versions') op.drop_table('content_versions') op.drop_index('idx_contents_created_by', table_name='contents') op.drop_index('idx_contents_content_type', table_name='contents') op.drop_index('idx_contents_status', table_name='contents') op.drop_index('idx_contents_project_id', table_name='contents') op.drop_index('idx_contents_organization_id', table_name='contents') op.drop_table('contents') op.drop_index('idx_agent_task_logs_created_at', table_name='agent_task_logs') op.drop_index('idx_agent_task_logs_agent_id', table_name='agent_task_logs') op.drop_index('idx_agent_task_logs_task_id', table_name='agent_task_logs') op.drop_table('agent_task_logs') op.drop_index('idx_agent_tasks_task_type', table_name='agent_tasks') op.drop_index('idx_agent_tasks_created_by', table_name='agent_tasks') op.drop_index('idx_agent_tasks_project_id', table_name='agent_tasks') op.drop_index('idx_agent_tasks_organization_id', table_name='agent_tasks') op.drop_index('idx_agent_tasks_status', table_name='agent_tasks') op.drop_index('idx_agent_tasks_agent_id', table_name='agent_tasks') op.drop_table('agent_tasks') op.drop_index('idx_agent_configs_agent_key', table_name='agent_configs') op.drop_index('idx_agent_configs_agent_id', table_name='agent_configs') op.drop_table('agent_configs') op.drop_index('idx_agent_registry_status', table_name='agent_registry') op.drop_index('idx_agent_registry_agent_type', table_name='agent_registry') op.drop_index('idx_agent_registry_name', table_name='agent_registry') op.drop_table('agent_registry') op.drop_index('idx_project_stages_project_stage', table_name='project_stages') op.drop_index('idx_project_stages_status', table_name='project_stages') op.drop_index('idx_project_stages_project_id', table_name='project_stages') op.drop_table('project_stages') op.drop_index('idx_lifecycle_projects_brand_name', table_name='lifecycle_projects') op.drop_index('idx_lifecycle_projects_status', table_name='lifecycle_projects') op.drop_index('idx_lifecycle_projects_organization_id', table_name='lifecycle_projects') op.drop_table('lifecycle_projects') op.drop_index('idx_org_members_org_user', table_name='org_members') op.drop_index('idx_org_members_user_id', table_name='org_members') op.drop_index('idx_org_members_organization_id', table_name='org_members') op.drop_table('org_members') # Revert users table changes op.drop_index('idx_users_organization_id', table_name='users') op.drop_constraint('fk_users_organization_id', 'users', type_='foreignkey') op.drop_column('users', 'role') op.drop_column('users', 'organization_id') op.drop_index('idx_organizations_slug', table_name='organizations') op.drop_table('organizations')