Migration 019 only backfills trees with team_id IS NOT NULL. Migration 3a40fe11b427 only covered is_default=TRUE trees. Trees with team_id=NULL and is_default=FALSE (e.g. inactive test trees, pre-team-system content) fell through both passes and triggered the NULL guard. Add two new UPDATE steps after the is_default pass: 1. Assign remaining trees to their author's account (if author has one) 2. Final fallback to PLATFORM_ACCOUNT_ID for any still-NULL rows Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
176 lines
7.5 KiB
Python
176 lines
7.5 KiB
Python
"""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')
|