Files
resolutionflow/backend/app/api/endpoints/workspaces.py
Michael Chihlas d6f4286570 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>
2026-02-15 01:16:33 -05:00

155 lines
5.0 KiB
Python

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()