From b4b8c67d3b166c7f01874913953bf873abe9c1a2 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 9 Apr 2026 05:31:33 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20Group=2010=20=E2=80=94=20cr?= =?UTF-8?q?eate=20global=20content=20tables=20and=20platform=20account?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates template_trees and platform_steps (no account_id, no RLS). Migrates is_default=TRUE trees and public steps into them. Creates sentinel platform account (00000000-...-0001) for global tree_categories, tree_tags, step_categories, step_library, and is_default trees — clearing all NULL account_id rows in those tables as prerequisite for Group 9 SET NOT NULL. Co-Authored-By: Claude Sonnet 4.6 --- ...40fe11b427_create_global_content_tables.py | 145 ++++++++++++++++++ backend/app/models/__init__.py | 4 + backend/app/models/platform_step.py | 37 +++++ backend/app/models/template_tree.py | 40 +++++ backend/tests/test_phase1_migrations.py | 28 ++++ 5 files changed, 254 insertions(+) create mode 100644 backend/alembic/versions/3a40fe11b427_create_global_content_tables.py create mode 100644 backend/app/models/platform_step.py create mode 100644 backend/app/models/template_tree.py diff --git a/backend/alembic/versions/3a40fe11b427_create_global_content_tables.py b/backend/alembic/versions/3a40fe11b427_create_global_content_tables.py new file mode 100644 index 00000000..c3eae00b --- /dev/null +++ b/backend/alembic/versions/3a40fe11b427_create_global_content_tables.py @@ -0,0 +1,145 @@ +"""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 ───────────────────────── + op.execute(""" + INSERT INTO template_trees + (id, name, description, category, tree_type, tree_structure, + is_active, created_at, updated_at, source_tree_id) + SELECT + gen_random_uuid(), name, description, category, tree_type, + tree_structure, is_active, + COALESCE(created_at, NOW()), COALESCE(updated_at, NOW()), id + FROM trees + WHERE 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 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') diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index fd3a754a..0441624f 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -54,6 +54,8 @@ from .session_branch import SessionBranch from .fork_point import ForkPoint from .session_handoff import SessionHandoff from .session_resolution_output import SessionResolutionOutput +from .template_tree import TemplateTree +from .platform_step import PlatformStep __all__ = [ "User", @@ -122,4 +124,6 @@ __all__ = [ "ForkPoint", "SessionHandoff", "SessionResolutionOutput", + "TemplateTree", + "PlatformStep", ] diff --git a/backend/app/models/platform_step.py b/backend/app/models/platform_step.py new file mode 100644 index 00000000..39e79733 --- /dev/null +++ b/backend/app/models/platform_step.py @@ -0,0 +1,37 @@ +"""Platform step model — platform-owned steps, readable by all users. + +No account_id. No RLS. Readable by any authenticated user. +Populated by promoting visibility='public' steps from step_library. +""" +import uuid +from datetime import datetime, timezone +from typing import Optional, Any + +from sqlalchemy import String, Boolean, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID, JSONB + +from app.core.database import Base + + +class PlatformStep(Base): + __tablename__ = "platform_steps" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title: Mapped[str] = mapped_column(String(255), nullable=False) + step_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + content: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + source_step_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("step_library.id", ondelete="SET NULL"), + nullable=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/models/template_tree.py b/backend/app/models/template_tree.py new file mode 100644 index 00000000..e67f70ec --- /dev/null +++ b/backend/app/models/template_tree.py @@ -0,0 +1,40 @@ +"""Template tree model — platform-owned troubleshooting trees, readable by all users. + +No account_id. No RLS. Readable by any authenticated user. +Populated by promoting is_default=TRUE trees from the trees table. +""" +import uuid +from datetime import datetime, timezone +from typing import Optional, Any + +from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID, JSONB + +from app.core.database import Base + + +class TemplateTree(Base): + __tablename__ = "template_trees" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + tree_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + tree_structure: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + tags: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + source_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("trees.id", ondelete="SET NULL"), + nullable=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/tests/test_phase1_migrations.py b/backend/tests/test_phase1_migrations.py index 06b09b6b..8d35ef29 100644 --- a/backend/tests/test_phase1_migrations.py +++ b/backend/tests/test_phase1_migrations.py @@ -478,3 +478,31 @@ async def test_target_list_account_id_from_team_admin(test_db: AsyncSession): ) row = result.scalar_one() assert row.account_id == account.id + + +# ── Group 10 (runs first): Global content tables ────────────────────────────── + +@pytest.mark.asyncio +async def test_template_trees_table_exists_and_has_no_account_id(test_db: AsyncSession): + """template_trees must exist and must NOT have an account_id column.""" + result = await test_db.execute(text(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'template_trees' + """)) + columns = {row[0] for row in result.fetchall()} + assert 'id' in columns, "template_trees.id must exist" + assert 'account_id' not in columns, "template_trees must not have account_id (global content)" + + +@pytest.mark.asyncio +async def test_platform_steps_table_exists_and_has_no_account_id(test_db: AsyncSession): + """platform_steps must exist and must NOT have an account_id column.""" + result = await test_db.execute(text(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'platform_steps' + """)) + columns = {row[0] for row in result.fetchall()} + assert 'id' in columns, "platform_steps.id must exist" + assert 'account_id' not in columns, "platform_steps must not have account_id (global content)"