feat: remove workspace system, add pinned flows and label renames

Replace workspace system with pinned flows API (pin/unpin/list/reorder).
Rename user-facing labels: Tree→Flow, Procedure→Project. Add sidebar
nav sub-items for flow type filtering. Remove 11 workspace files,
add migrations 037-038, clean all workspace references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-15 12:06:10 -05:00
parent 336e37d018
commit 58a55f4d88
39 changed files with 1612 additions and 2288 deletions

View File

@@ -17,8 +17,10 @@ from app.models.folder import UserFolder, user_folder_trees
from app.schemas.tree import (
TreeCreate, TreeUpdate, TreeResponse, TreeListResponse, CategoryInfo,
ForkCreate, ForkInfo, TreeShareCreate, TreeShareResponse,
TreeVisibilityUpdate, SharedTreeResponse, TreeValidationResponse, ValidationError
TreeVisibilityUpdate, SharedTreeResponse, TreeValidationResponse, ValidationError,
PinnedFlowResponse, PinnedFlowsListResponse, PinnedFlowReorderRequest
)
from app.models.user_pinned_tree import UserPinnedTree
from app.api.deps import get_current_active_user, require_engineer_or_admin, require_admin
from app.core.permissions import can_edit_tree, can_access_tree
from app.core.filters import build_tree_access_filter
@@ -1030,3 +1032,185 @@ async def check_tree_can_publish(
can_publish=can_publish,
errors=[ValidationError(**error) for error in validation_errors]
)
# --- Pinned Flows Endpoints ---
MAX_PINNED_FLOWS = 15
@router.get("/pinned", response_model=PinnedFlowsListResponse)
async def list_pinned_flows(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""List user's pinned flows, ordered by display_order."""
result = await db.execute(
select(UserPinnedTree, Tree)
.join(Tree, UserPinnedTree.tree_id == Tree.id)
.options(selectinload(Tree.category_rel))
.where(
UserPinnedTree.user_id == current_user.id,
Tree.is_active == True,
Tree.deleted_at.is_(None)
)
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
)
rows = result.all()
items = []
for pin, tree in rows:
items.append(PinnedFlowResponse(
id=pin.id,
tree_id=tree.id,
tree_name=tree.name,
tree_type=tree.tree_type,
category_emoji=None,
category_name=tree.category_rel.name if tree.category_rel else None,
pinned_at=pin.pinned_at,
display_order=pin.display_order,
))
return PinnedFlowsListResponse(items=items, count=len(items))
@router.post("/{tree_id}/pin", response_model=PinnedFlowResponse)
async def pin_flow(
tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Pin a flow to the user's sidebar."""
# Check tree exists and user can access it
tree_result = await db.execute(
select(Tree)
.options(selectinload(Tree.category_rel))
.where(Tree.id == tree_id, Tree.is_active == True)
)
tree = tree_result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tree not found")
if not can_access_tree(current_user, tree):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this tree")
# Check if already pinned (idempotent)
existing = await db.execute(
select(UserPinnedTree).where(
UserPinnedTree.user_id == current_user.id,
UserPinnedTree.tree_id == tree_id
)
)
pin = existing.scalar_one_or_none()
if pin:
return PinnedFlowResponse(
id=pin.id,
tree_id=tree.id,
tree_name=tree.name,
tree_type=tree.tree_type,
category_emoji=None,
category_name=tree.category_rel.name if tree.category_rel else None,
pinned_at=pin.pinned_at,
display_order=pin.display_order,
)
# Check max pins
count_result = await db.execute(
select(func.count(UserPinnedTree.id)).where(
UserPinnedTree.user_id == current_user.id
)
)
count = count_result.scalar() or 0
if count >= MAX_PINNED_FLOWS:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Maximum of {MAX_PINNED_FLOWS} pinned flows reached"
)
# Create pin
pin = UserPinnedTree(
user_id=current_user.id,
tree_id=tree_id,
display_order=count, # Append at end
)
db.add(pin)
await db.commit()
await db.refresh(pin)
return PinnedFlowResponse(
id=pin.id,
tree_id=tree.id,
tree_name=tree.name,
tree_type=tree.tree_type,
category_emoji=None,
category_name=tree.category_rel.name if tree.category_rel else None,
pinned_at=pin.pinned_at,
display_order=pin.display_order,
)
@router.delete("/{tree_id}/pin")
async def unpin_flow(
tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Unpin a flow from the user's sidebar."""
result = await db.execute(
select(UserPinnedTree).where(
UserPinnedTree.user_id == current_user.id,
UserPinnedTree.tree_id == tree_id
)
)
pin = result.scalar_one_or_none()
if pin:
await db.delete(pin)
await db.commit()
return {"success": True}
@router.patch("/pinned/reorder", response_model=PinnedFlowsListResponse)
async def reorder_pinned_flows(
reorder_data: PinnedFlowReorderRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Update display_order for all pinned flows."""
for item in reorder_data.order:
await db.execute(
update(UserPinnedTree)
.where(
UserPinnedTree.user_id == current_user.id,
UserPinnedTree.tree_id == item.tree_id
)
.values(display_order=item.display_order)
)
await db.commit()
# Return updated list
result = await db.execute(
select(UserPinnedTree, Tree)
.join(Tree, UserPinnedTree.tree_id == Tree.id)
.options(selectinload(Tree.category_rel))
.where(
UserPinnedTree.user_id == current_user.id,
Tree.is_active == True,
Tree.deleted_at.is_(None)
)
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
)
rows = result.all()
items = []
for pin, tree in rows:
items.append(PinnedFlowResponse(
id=pin.id,
tree_id=tree.id,
tree_name=tree.name,
tree_type=tree.tree_type,
category_emoji=None,
category_name=tree.category_rel.name if tree.category_rel else None,
pinned_at=pin.pinned_at,
display_order=pin.display_order,
))
return PinnedFlowsListResponse(items=items, count=len(items))

View File

@@ -1,154 +0,0 @@
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()