feat: add workspace system and sidebar layout (UI design system Phase A+B)

Backend: Workspace model, migration (036), schemas, CRUD API endpoints.
Adds workspace_id to trees and categories, seeds 4 default workspaces
per account, auto-assigns existing trees by tree_type.

Frontend: Complete AppLayout rewrite from top-nav to CSS Grid shell
with persistent sidebar + topbar. New components: WorkspaceSwitcher,
NavItem, CategoryList, TagCloud, TopBar, Sidebar. Dashboard components:
QuickStats, FiltersBar, SectionGroup, TreeListItem, SessionsPanel.
WorkspaceStore with localStorage persistence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-15 01:16:33 -05:00
parent ef829f06a4
commit d6f4286570
31 changed files with 1431 additions and 250 deletions

View File

@@ -0,0 +1,102 @@
"""Add workspaces table, workspace_id to trees and categories, color to categories
Revision ID: 036
Revises: 035
Create Date: 2026-02-15
Adds workspace system for organizational context above folders.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = '036'
down_revision: Union[str, None] = '035'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create workspaces table
op.create_table(
'workspaces',
sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('slug', sa.String(100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('icon', sa.String(10), nullable=True),
sa.Column('accent_color', sa.String(7), nullable=True),
sa.Column('account_id', UUID(as_uuid=True), sa.ForeignKey('accounts.id', ondelete='CASCADE'), nullable=False),
sa.Column('is_default', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
sa.UniqueConstraint('slug', 'account_id', name='uq_workspaces_slug_account'),
)
op.create_index('ix_workspaces_slug', 'workspaces', ['slug'])
op.create_index('ix_workspaces_account_id', 'workspaces', ['account_id'])
# Add workspace_id to trees
op.add_column('trees', sa.Column('workspace_id', UUID(as_uuid=True), nullable=True))
op.create_foreign_key('fk_trees_workspace_id', 'trees', 'workspaces', ['workspace_id'], ['id'], ondelete='SET NULL')
op.create_index('ix_trees_workspace_id', 'trees', ['workspace_id'])
# Add color and workspace_id to tree_categories
op.add_column('tree_categories', sa.Column('color', sa.String(7), nullable=True, server_default='#3b82f6'))
op.add_column('tree_categories', sa.Column('workspace_id', UUID(as_uuid=True), nullable=True))
op.create_foreign_key('fk_tree_categories_workspace_id', 'tree_categories', 'workspaces', ['workspace_id'], ['id'], ondelete='SET NULL')
op.create_index('ix_tree_categories_workspace_id', 'tree_categories', ['workspace_id'])
# Seed default workspaces for each existing account and assign trees
op.execute("""
-- Create default workspaces for each account
INSERT INTO workspaces (name, slug, description, icon, accent_color, account_id, is_default, sort_order)
SELECT 'Troubleshooting', 'troubleshooting', 'Break/fix decision trees', '🔧', '#ef4444', a.id, true, 0
FROM accounts a;
INSERT INTO workspaces (name, slug, description, icon, accent_color, account_id, is_default, sort_order)
SELECT 'Procedures', 'procedures', 'Step-by-step operational flows', '📋', '#3b82f6', a.id, false, 1
FROM accounts a;
INSERT INTO workspaces (name, slug, description, icon, accent_color, account_id, is_default, sort_order)
SELECT 'Policies', 'policies', 'Compliance & policy builders', '📜', '#8b5cf6', a.id, false, 2
FROM accounts a;
INSERT INTO workspaces (name, slug, description, icon, accent_color, account_id, is_default, sort_order)
SELECT 'Finance', 'finance', 'Billing & procurement flows', '💰', '#22c55e', a.id, false, 3
FROM accounts a;
-- Assign existing trees to appropriate workspace based on tree_type
UPDATE trees t
SET workspace_id = w.id
FROM workspaces w
WHERE w.account_id = t.account_id
AND w.slug = 'troubleshooting'
AND t.tree_type = 'troubleshooting';
UPDATE trees t
SET workspace_id = w.id
FROM workspaces w
WHERE w.account_id = t.account_id
AND w.slug = 'procedures'
AND t.tree_type = 'procedural';
""")
def downgrade() -> None:
op.drop_index('ix_tree_categories_workspace_id', 'tree_categories')
op.drop_constraint('fk_tree_categories_workspace_id', 'tree_categories', type_='foreignkey')
op.drop_column('tree_categories', 'workspace_id')
op.drop_column('tree_categories', 'color')
op.drop_index('ix_trees_workspace_id', 'trees')
op.drop_constraint('fk_trees_workspace_id', 'trees', type_='foreignkey')
op.drop_column('trees', 'workspace_id')
op.drop_index('ix_workspaces_account_id', 'workspaces')
op.drop_index('ix_workspaces_slug', 'workspaces')
op.drop_table('workspaces')

View File

@@ -0,0 +1,154 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.models.workspace import Workspace
from app.models.tree import Tree
from app.models.user import User
from app.schemas.workspace import WorkspaceCreate, WorkspaceUpdate, WorkspaceResponse
from app.api.deps import get_current_active_user
router = APIRouter(prefix="/workspaces", tags=["workspaces"])
@router.get("", response_model=list[WorkspaceResponse])
async def list_workspaces(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""List all workspaces for the user's account."""
if not current_user.account_id:
return []
# Get workspaces with tree counts
query = (
select(
Workspace,
func.count(Tree.id).label("tree_count")
)
.outerjoin(Tree, (Tree.workspace_id == Workspace.id) & (Tree.deleted_at.is_(None)))
.where(Workspace.account_id == current_user.account_id)
.group_by(Workspace.id)
.order_by(Workspace.sort_order, Workspace.name)
)
result = await db.execute(query)
rows = result.all()
return [
WorkspaceResponse(
**{c.key: getattr(ws, c.key) for c in Workspace.__table__.columns},
tree_count=tree_count
)
for ws, tree_count in rows
]
@router.post("", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED)
async def create_workspace(
data: WorkspaceCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Create a new workspace."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="No account found")
# Check slug uniqueness within account
existing = await db.execute(
select(Workspace).where(
Workspace.account_id == current_user.account_id,
Workspace.slug == data.slug
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Workspace slug already exists")
workspace = Workspace(
name=data.name,
slug=data.slug,
description=data.description,
icon=data.icon,
accent_color=data.accent_color,
account_id=current_user.account_id,
sort_order=data.sort_order,
)
db.add(workspace)
await db.flush()
await db.commit()
return WorkspaceResponse(
**{c.key: getattr(workspace, c.key) for c in Workspace.__table__.columns},
tree_count=0
)
@router.patch("/{workspace_id}", response_model=WorkspaceResponse)
async def update_workspace(
workspace_id: UUID,
data: WorkspaceUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Update a workspace."""
workspace = await db.get(Workspace, workspace_id)
if not workspace or workspace.account_id != current_user.account_id:
raise HTTPException(status_code=404, detail="Workspace not found")
update_data = data.model_dump(exclude_unset=True)
if "slug" in update_data:
existing = await db.execute(
select(Workspace).where(
Workspace.account_id == current_user.account_id,
Workspace.slug == update_data["slug"],
Workspace.id != workspace_id
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Workspace slug already exists")
for key, value in update_data.items():
setattr(workspace, key, value)
await db.commit()
# Get tree count
count_result = await db.execute(
select(func.count(Tree.id)).where(
Tree.workspace_id == workspace_id,
Tree.deleted_at.is_(None)
)
)
tree_count = count_result.scalar() or 0
return WorkspaceResponse(
**{c.key: getattr(workspace, c.key) for c in Workspace.__table__.columns},
tree_count=tree_count
)
@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_workspace(
workspace_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Delete a workspace. Trees are unassigned, not deleted."""
workspace = await db.get(Workspace, workspace_id)
if not workspace or workspace.account_id != current_user.account_id:
raise HTTPException(status_code=404, detail="Workspace not found")
if workspace.is_default:
raise HTTPException(status_code=400, detail="Cannot delete the default workspace")
# Unassign trees (set workspace_id to NULL)
await db.execute(
Tree.__table__.update()
.where(Tree.workspace_id == workspace_id)
.values(workspace_id=None)
)
await db.delete(workspace)
await db.commit()

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown, workspaces
from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories
api_router = APIRouter()
@@ -25,3 +25,4 @@ api_router.include_router(webhooks.router)
api_router.include_router(shares.router)
api_router.include_router(shared.router) # Public endpoints (no auth)
api_router.include_router(tree_markdown.router)
api_router.include_router(workspaces.router)

View File

@@ -20,6 +20,7 @@ from .session_share import SessionShare, SessionShareView
from .account_limit_override import AccountLimitOverride
from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
from .platform_setting import PlatformSetting
from .workspace import Workspace
__all__ = [
"User",
@@ -51,4 +52,5 @@ __all__ = [
"PlanFeatureDefault",
"AccountFeatureOverride",
"PlatformSetting",
"Workspace",
]

View File

@@ -15,6 +15,7 @@ if TYPE_CHECKING:
from app.models.step_category import StepCategory
from app.models.step_library import StepLibrary
from app.models.account_limit_override import AccountLimitOverride
from app.models.workspace import Workspace
class Account(Base):
@@ -45,3 +46,4 @@ class Account(Base):
step_categories: Mapped[list["StepCategory"]] = relationship("StepCategory", foreign_keys="[StepCategory.account_id]", back_populates="account")
step_library: Mapped[list["StepLibrary"]] = relationship("StepLibrary", foreign_keys="[StepLibrary.account_id]", back_populates="account")
limit_override: Mapped[Optional["AccountLimitOverride"]] = relationship("AccountLimitOverride", back_populates="account", uselist=False)
workspaces: Mapped[list["Workspace"]] = relationship("Workspace", back_populates="account")

View File

@@ -11,6 +11,7 @@ if TYPE_CHECKING:
from app.models.team import Team
from app.models.account import Account
from app.models.user import User
from app.models.workspace import Workspace
class TreeCategory(Base):
@@ -47,6 +48,17 @@ class TreeCategory(Base):
)
display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
color: Mapped[Optional[str]] = mapped_column(
String(7), nullable=True, default='#3b82f6',
comment="Hex color for category dot indicator"
)
workspace_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workspaces.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="Workspace this category belongs to"
)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
@@ -67,6 +79,7 @@ class TreeCategory(Base):
account: Mapped[Optional["Account"]] = relationship("Account", foreign_keys=[account_id], back_populates="categories")
creator: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by])
trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="category_rel")
workspace: Mapped[Optional["Workspace"]] = relationship("Workspace", back_populates="categories")
@property
def is_global(self) -> bool:

View File

@@ -15,6 +15,7 @@ if TYPE_CHECKING:
from app.models.tag import TreeTag
from app.models.folder import UserFolder
from app.models.tree_share import TreeShare
from app.models.workspace import Workspace
class Tree(Base):
@@ -120,6 +121,13 @@ class Tree(Base):
onupdate=lambda: datetime.now(timezone.utc)
)
usage_count: Mapped[int] = mapped_column(Integer, default=0)
workspace_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workspaces.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="Workspace this tree belongs to (organizational context)"
)
# Fork tracking
parent_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
@@ -184,6 +192,8 @@ class Tree(Base):
cascade="all, delete-orphan"
)
workspace: Mapped[Optional["Workspace"]] = relationship("Workspace", back_populates="trees")
# New organization relationships
category_rel: Mapped[Optional["TreeCategory"]] = relationship("TreeCategory", back_populates="trees")
tags: Mapped[list["TreeTag"]] = relationship(

View File

@@ -0,0 +1,57 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
if TYPE_CHECKING:
from app.models.account import Account
from app.models.tree import Tree
from app.models.category import TreeCategory
class Workspace(Base):
"""Workspaces are the top-level organizational context for trees/flows.
They sit above the folder system — a workspace scopes which trees/flows
are visible, while folders remain for personal organization within.
"""
__tablename__ = "workspaces"
__table_args__ = (
UniqueConstraint('slug', 'account_id', name='uq_workspaces_slug_account'),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
icon: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
accent_color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True
)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
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)
)
# Relationships
account: Mapped["Account"] = relationship("Account", back_populates="workspaces")
trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="workspace")
categories: Mapped[list["TreeCategory"]] = relationship("TreeCategory", back_populates="workspace")

View File

@@ -36,6 +36,8 @@ class CategoryResponse(CategoryBase):
account_id: Optional[UUID] = None
display_order: int
is_active: bool
color: Optional[str] = None
workspace_id: Optional[UUID] = None
created_at: datetime
updated_at: datetime
tree_count: int = 0 # Computed field
@@ -52,6 +54,8 @@ class CategoryListResponse(BaseModel):
account_id: Optional[UUID] = None
display_order: int
is_active: bool
color: Optional[str] = None
workspace_id: Optional[UUID] = None
tree_count: int = 0
class Config:

View File

@@ -0,0 +1,39 @@
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class WorkspaceCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
slug: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-z0-9-]+$')
description: Optional[str] = None
icon: Optional[str] = Field(None, max_length=10)
accent_color: Optional[str] = Field(None, pattern=r'^#[0-9a-fA-F]{6}$')
sort_order: int = 0
class WorkspaceUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
slug: Optional[str] = Field(None, min_length=1, max_length=100, pattern=r'^[a-z0-9-]+$')
description: Optional[str] = None
icon: Optional[str] = Field(None, max_length=10)
accent_color: Optional[str] = Field(None, pattern=r'^#[0-9a-fA-F]{6}$')
sort_order: Optional[int] = None
class WorkspaceResponse(BaseModel):
id: uuid.UUID
name: str
slug: str
description: Optional[str] = None
icon: Optional[str] = None
accent_color: Optional[str] = None
account_id: uuid.UUID
is_default: bool
sort_order: int
tree_count: int = 0
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}