feat: Phase 1 Group 10 — create global content tables and platform account
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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')
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
37
backend/app/models/platform_step.py
Normal file
37
backend/app/models/platform_step.py
Normal file
@@ -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),
|
||||
)
|
||||
40
backend/app/models/template_tree.py
Normal file
40
backend/app/models/template_tree.py
Normal file
@@ -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),
|
||||
)
|
||||
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user