"""create template_trees and platform_steps global content tables Revision ID: 3a40fe11b427 Revises: 2c6aabd89bc6 Create Date: 2026-04-09 00:00:00.000000 These tables hold platform-owned content that is readable by all authenticated users. No account_id. No RLS. Ever. """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa from sqlalchemy.dialects.postgresql import UUID, JSONB revision: str = '3a40fe11b427' down_revision: Union[str, None] = '2c6aabd89bc6' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ── Create template_trees ───────────────────────────────────────────────── op.create_table( 'template_trees', sa.Column('id', UUID(), primary_key=True), sa.Column('name', sa.String(255), nullable=False), sa.Column('description', sa.Text(), nullable=True), sa.Column('category', sa.String(100), nullable=True), sa.Column('tree_type', sa.String(20), nullable=False), sa.Column('tree_structure', JSONB(), nullable=False), sa.Column('tags', JSONB(), nullable=False, server_default='[]'), sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), sa.Column('source_tree_id', UUID(), sa.ForeignKey('trees.id', ondelete='SET NULL'), nullable=True), ) op.create_index('ix_template_trees_tree_type', 'template_trees', ['tree_type']) # ── Create platform_steps ──────────────────────────────────────────────── op.create_table( 'platform_steps', sa.Column('id', UUID(), primary_key=True), sa.Column('title', sa.String(255), nullable=False), sa.Column('step_type', sa.String(50), nullable=False), sa.Column('content', JSONB(), nullable=False), sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), sa.Column('source_step_id', UUID(), sa.ForeignKey('step_library.id', ondelete='SET NULL'), nullable=True), ) op.create_index('ix_platform_steps_step_type', 'platform_steps', ['step_type']) # ── Copy is_default=TRUE trees → template_trees ───────────────────────── # Note: trees.tags is a relationship via tree_tags join table — no direct column. # Aggregate tag names via a correlated subquery. op.execute(""" INSERT INTO template_trees (id, name, description, category, tree_type, tree_structure, tags, is_active, created_at, updated_at, source_tree_id) SELECT gen_random_uuid(), t.name, t.description, t.category, t.tree_type, t.tree_structure, COALESCE( (SELECT jsonb_agg(tt.name ORDER BY tt.name) FROM tree_tag_assignments ta JOIN tree_tags tt ON tt.id = ta.tag_id WHERE ta.tree_id = t.id), '[]'::jsonb ), t.is_active, COALESCE(t.created_at, NOW()), COALESCE(t.updated_at, NOW()), t.id FROM trees t WHERE t.is_default = TRUE """) # ── Copy visibility='public' steps → platform_steps ───────────────────── op.execute(""" INSERT INTO platform_steps (id, title, step_type, content, is_active, created_at, updated_at, source_step_id) SELECT gen_random_uuid(), title, step_type, content, is_active, COALESCE(created_at, NOW()), COALESCE(updated_at, NOW()), id FROM step_library WHERE visibility = 'public' """) # ── Create platform sentinel account ───────────────────────────────────── op.execute(""" INSERT INTO accounts (id, name, display_code, created_at, updated_at) VALUES ( '00000000-0000-0000-0000-000000000001', 'ResolutionFlow Platform', 'PLATFORM', NOW(), NOW() ) ON CONFLICT (id) DO NOTHING """) # ── Assign is_default trees to platform account ────────────────────────── op.execute(""" UPDATE trees SET account_id = '00000000-0000-0000-0000-000000000001' WHERE is_default = TRUE AND account_id IS NULL """) # ── Assign remaining trees to their author's account ───────────────────── # Handles trees with no team_id that aren't is_default (e.g. inactive test # trees, trees created before the team system existed). op.execute(""" UPDATE trees SET account_id = u.account_id FROM users u WHERE trees.author_id = u.id AND trees.account_id IS NULL AND u.account_id IS NOT NULL """) # ── Final fallback: any still-NULL trees go to platform account ─────────── # Covers trees whose author has no account (seeded content, system rows). op.execute(""" UPDATE trees SET account_id = '00000000-0000-0000-0000-000000000001' WHERE account_id IS NULL """) # ── Assign global categories/tags/steps to platform account ───────────── op.execute(""" UPDATE tree_categories SET account_id = '00000000-0000-0000-0000-000000000001' WHERE account_id IS NULL """) op.execute(""" UPDATE tree_tags SET account_id = '00000000-0000-0000-0000-000000000001' WHERE account_id IS NULL """) op.execute(""" UPDATE step_categories SET account_id = '00000000-0000-0000-0000-000000000001' WHERE account_id IS NULL """) op.execute(""" UPDATE step_library SET account_id = '00000000-0000-0000-0000-000000000001' WHERE account_id IS NULL """) # ── Verify zero NULLs in all 5 tables ─────────────────────────────────── for table in ('trees', 'tree_categories', 'tree_tags', 'step_categories', 'step_library'): result = op.get_bind().execute( sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL") ) count = result.scalar() if count > 0: raise RuntimeError( f"ROLLBACK: {count} NULL account_id rows remain in {table} " "after platform account assignment. Investigate before re-running." ) def downgrade() -> None: platform_id = '00000000-0000-0000-0000-000000000001' for table in ('trees', 'tree_categories', 'tree_tags', 'step_categories', 'step_library'): op.execute(f"UPDATE {table} SET account_id = NULL WHERE account_id = '{platform_id}'") op.execute(f"DELETE FROM accounts WHERE id = '{platform_id}'") op.drop_index('ix_platform_steps_step_type', table_name='platform_steps') op.drop_index('ix_template_trees_tree_type', table_name='template_trees') op.drop_table('platform_steps') op.drop_table('template_trees')