* feat: add flow export/import backend (migration, endpoints, schemas)
Add .rfflow file export/import support:
- Migration 050: import_metadata JSONB column on trees
- GET /trees/{id}/export?format=json|xml endpoint
- POST /trees/import endpoint (creates draft, resolves categories/tags)
- FlowExportEnvelope, FlowImportRequest/Response schemas
- import_metadata field on TreeResponse
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add flow export/import frontend + backend tests
Frontend:
- ExportFlowModal with JSON/XML format selection + download
- ImportFlowModal with drag-drop file picker + preview step
- rfflowParser for client-side JSON/XML .rfflow parsing
- Export buttons on editor toolbar and library action menus
- Import button on library page next to Create New
- Provenance display for imported flows in editor
- flowTransfer API client + types
Backend:
- Fix regex->pattern deprecation in export endpoint
- 12 integration tests covering export, import, round-trip,
access control, tag/category creation, version validation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: remove XML export, JSON-only for .rfflow files
- Remove XML builder, format query param, and XML tests
- Simplify ExportFlowModal (no format picker)
- Simplify rfflowParser (JSON-only)
- Remove format field from schemas and types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: match Flow Assist chat input to AI Assistant styling + strengthen one-question prompt
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add procedural flow support to AI chat builder (Flow Assist)
- Add procedural-specific system prompts (schema, interview protocol, response format)
- Dispatch prompts by flow_type: procedural/maintenance use flat steps schema, troubleshooting uses decision tree schema
- Parse [STEPS_UPDATE] and [INTAKE_FORM] markers in AI responses
- Add validate_generated_procedural_steps() validator
- Handle intake form extraction in AI chat import endpoint
- Add StaticStepsPreview component for procedural flow preview
- Update store and page to render correct preview by flow type
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add flow type selection to Flow Assist entry points
- CreateFlowDropdown now shows "Build with Flow Assist" under each flow type
- Library page "Flow Assist" button respects current type filter
- Clean up unused AIFlowBuilderModal references
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update CLAUDE.md with AI chat builder and intake form learnings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: refine assistant chat prompt for concise answers and focused questions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: switch AI provider to Claude Sonnet 4.6 + add shift+enter hint to chat inputs
- Default AI_PROVIDER changed from gemini to anthropic
- AI_MODEL and AI_MODEL_ANTHROPIC updated to claude-sonnet-4-6
- Added "Shift + Enter for a new line" hint below all chat textareas
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update CLAUDE.md with AI provider and chat input learnings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add editor-embedded Flow Assist design document
Design for replacing the standalone /ai/chat page with context-aware
AI side panels embedded in each editor (Troubleshooting + Procedural).
Covers ghost node suggestion system, output-based thresholds,
config-driven model routing, knowledge integration, and per-flow
chat persistence.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add editor-embedded Flow Assist implementation plan
25-task plan across 9 phases covering backend foundation, frontend
infrastructure, tree/procedural editor integration, AI-assisted create,
old code removal, action-type dispatch, suggestion audit trail, and
build verification.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use actual root node ID in orphan validation check
AI-generated trees use descriptive IDs (e.g., "verify-account-exists")
instead of "root", causing the root node to be falsely flagged as orphaned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add config-driven AI model tier routing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: extend AI chat session with tree_id and archived_at
Add tree_id FK (CASCADE) for editor-embedded sessions and archived_at
timestamp column to ai_chat_sessions table.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add AI suggestion audit trail table
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add action_type and focal_node_id to AI chat message API
- Add VALID_ACTION_TYPES literal and action_type/focal_node_id fields to
AIChatMessageRequest schema
- Add tree_id field to AIChatStartRequest schema for editor-embedded sessions
- Update send_message() signature with action_type and focal_node_id params
- Update start_chat_session() signature with tree_id param
- Pass new params through endpoints to service functions
- All new params have defaults so existing behavior is unchanged
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: route AI model selection through action-type config
Update get_ai_provider() to accept an optional model override parameter
(applied only to AnthropicProvider; Gemini always uses its own model).
Thread action_type-based model resolution through send_message() and
generate_final_tree() in the AI chat service.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add TypeScript types for editor-embedded AI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add shared ContextMenu component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add useEditorAI hook and editorAI API client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add EditorAIPanel component with Chat and Suggestions tabs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: integrate AI panel, context menu, and ghost nodes in tree editor
- Add AI Assist panel toggle button to tree editor toolbar
- Wire EditorAIPanel alongside TreeEditorLayout with single-panel rule
- Thread onNodeContextMenu through TreeEditorLayout → FlowCanvas → FlowCanvasNode
- Add right-click context menu with Generate Branch, Explain Node, Delete actions
- Add ghost node detection (_suggestion flag) with dashed border + opacity styling
- Add Accept/Dismiss overlay buttons on ghost nodes for future suggestion handling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: integrate AI panel, context menu, and ghost steps in procedural editor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add AI prompt dialog and wire into CreateFlowDropdown
Replace navigation to /ai/chat with an inline AIPromptDialog modal
that collects a single prompt, generates a flow via the editor AI API,
imports it, and navigates to the editor with the AI panel open.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add glassmorphism to AI prompt dialog + maintenance Flow Assist button
- Use .glass-card-static on AIPromptDialog card for consistent design system
- Add "Build with Flow Assist" button to maintenance section in CreateFlowDropdown
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: remove standalone Flow Assist page and old AI chat components
Remove the old /ai/chat page, AI wizard modal, and all associated
components/stores/types now replaced by the editor-embedded AI panel.
Deleted:
- AIChatBuilderPage, ai-chat/ components, aiChatStore, aiChat API, ai-chat types
- AIFlowBuilderModal, ai-builder/ components, aiFlowBuilderStore
Cleaned up:
- Router (removed /ai/chat route)
- Sidebar (removed Flow Assist nav item)
- MyTreesPage (removed AI builder modal and button)
- TreeLibraryPage (removed Flow Assist button)
- API and type barrel exports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add delta response parsing and action-type prompt dispatch
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add AI suggestion audit trail endpoints
Create/list/resolve endpoints for tracking AI-applied changes to flows.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add APScheduler task to auto-archive stale AI chat sessions
Archives AI chat sessions with no activity for 30 days, runs daily at 3 AM.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update project status for editor-embedded Flow Assist
- Add Editor-Embedded Flow Assist to CURRENT-STATE.md in-progress items
- Update CLAUDE.md: fix stale lessons (#41, #46), add new patterns (#47 editor AI architecture, #48 orphan validation)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use correct model alias in AI_MODEL_TIERS standard tier
The dated model ID `claude-sonnet-4-6-20250514` was causing 502 errors.
Use the alias `claude-sonnet-4-6` which matches AI_MODEL_ANTHROPIC.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: send live flow context to AI Assist for full editor awareness
The AI panel now sends the current tree structure (troubleshooting) or
steps + intake form (procedural/maintenance) with each message. This
gives the AI full visibility into node details, questions, descriptions,
options, and intake form fields — not just the node ID.
- Backend: add flow_context param to schema, endpoint, and service
- Frontend: add getFlowContext callback to useEditorAI hook
- TreeEditorPage: passes treeStructure as flow context
- ProceduralEditorPage: passes steps + intakeForm as flow context
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: include flow name and description in AI Assist context
Both editors now send name and description alongside the flow structure,
so the AI can reference what the flow is about when responding.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: increase AI timeout to 120s and limit retries to 1
The 45s timeout was too short for generation tasks with full flow
context in the system prompt. The Anthropic SDK's default 2 retries
caused requests to hang for ~136s before failing. Now: 120s timeout
with max 1 retry = faster failure if it does timeout.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: wire AI-generated flow structures into editor stores
The useEditorAI hook was ignoring result.working_tree from AI responses,
so generated steps/trees never appeared in the editor. Now:
- useEditorAI calls onFlowUpdate when working_tree is present in response
- ProceduralEditorPage handles steps + intake form updates via replaceSteps
- TreeEditorPage handles tree structure updates via replaceTreeStructure
- Both stores have new bulk-replace methods for AI integration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add lessons learned for full-stack integration, Anthropic retries, model tiers
#49 Always verify frontend consumes backend response fields
#50 Anthropic SDK max_retries=1 to avoid 3× timeout
#51 AI model tier routing via settings.get_model_for_action()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: move AI Assist panel to full-height side layout in both editors
The AI panel was nested inside the content area, only spanning the
step list / canvas section. Now it sits at the outermost flex level,
spanning the full page height alongside all content (toolbar,
collapsible sections, steps/canvas). This prevents the panel from
overlapping content and lets the editor area properly shrink.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: AI Assist panel as fixed right drawer (matching Copilot/Scratchpad)
Convert EditorAIPanel from in-flow flex child to fixed right-side drawer
overlay, same pattern as CopilotPanel and ScratchpadSidebar. The panel
is fixed at right:0 spanning full viewport height, and editor pages add
pr-[380px] padding when open so content shifts left without overlap.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: AI Assist panel sits below topbar with slide-in animation
- Panel now uses top:56px to sit below the app shell topbar instead of
covering it (matches the main-content grid cell area)
- Added slideInRight CSS animation for smooth drawer entrance
- Editor pages use dynamic paddingRight via PANEL_WIDTH constant
- ChatTab upgraded: markdown rendering, CopilotPanel-style message
bubbles, auto-focus input, Shift+Enter hint
- All borders use --glass-border for consistent glassmorphism
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: AI Assist panel as in-flow flex sibling (not fixed/overlay)
Replace fixed positioning with in-flow flex layout. The outermost div
is now a horizontal flex row: content column (flex-1 min-w-0) + panel
(w-[380px] shrink-0). When the panel opens, the content column
automatically shrinks — no padding hacks or z-index stacking needed.
This guarantees the content shifts left and stays fully visible.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: AI Copilot panel as in-flow flex sibling in session navigation pages
Changed CopilotPanel from fixed overlay to flex layout sibling so it
pushes main content instead of covering it during active sessions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: remove duplicate CLAUDE.md lessons #47-48
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1282 lines
43 KiB
Python
1282 lines
43 KiB
Python
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Annotated, Optional
|
|
from uuid import UUID
|
|
import secrets
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func, or_, update
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.core.database import get_db
|
|
from app.models.tree import Tree
|
|
from app.models.tree_share import TreeShare
|
|
from app.models.user import User
|
|
from app.models.category import TreeCategory
|
|
from app.models.tag import TreeTag, tree_tag_assignments
|
|
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,
|
|
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, get_service_account_id
|
|
from app.core.permissions import can_edit_tree, can_access_tree
|
|
from app.core.filters import build_tree_access_filter
|
|
from app.core.subscriptions import check_tree_limit
|
|
from app.core.audit import log_audit
|
|
from app.core.config import settings
|
|
from app.core.tree_validation import can_publish_tree
|
|
from app.core.step_sync import sync_steps_from_tree, deactivate_synced_steps_for_tree
|
|
from app.services.rag_service import index_tree as rag_index_tree
|
|
|
|
router = APIRouter(prefix="/trees", tags=["trees"])
|
|
|
|
|
|
def build_tree_response(tree: Tree, author_map: dict | None = None) -> TreeListResponse:
|
|
"""Build TreeListResponse with category_info and tags."""
|
|
category_info = None
|
|
if tree.category_rel:
|
|
category_info = CategoryInfo(
|
|
id=tree.category_rel.id,
|
|
name=tree.category_rel.name,
|
|
slug=tree.category_rel.slug
|
|
)
|
|
|
|
author_name = (author_map or {}).get(tree.author_id)
|
|
|
|
return TreeListResponse(
|
|
id=tree.id,
|
|
name=tree.name,
|
|
description=tree.description,
|
|
tree_type=tree.tree_type,
|
|
category=tree.category,
|
|
category_id=tree.category_id,
|
|
category_info=category_info,
|
|
tags=tree.tag_names,
|
|
author_id=tree.author_id,
|
|
author_name=author_name,
|
|
account_id=tree.account_id,
|
|
is_active=tree.is_active,
|
|
is_public=tree.is_public,
|
|
is_default=tree.is_default,
|
|
visibility=tree.visibility,
|
|
status=tree.status,
|
|
version=tree.version,
|
|
usage_count=tree.usage_count,
|
|
created_at=tree.created_at,
|
|
updated_at=tree.updated_at
|
|
)
|
|
|
|
|
|
def build_full_tree_response(tree: Tree, parent_tree: Tree = None) -> TreeResponse:
|
|
"""Build TreeResponse with all details including category_info, tags, and fork_info."""
|
|
category_info = None
|
|
if tree.category_rel:
|
|
category_info = CategoryInfo(
|
|
id=tree.category_rel.id,
|
|
name=tree.category_rel.name,
|
|
slug=tree.category_rel.slug
|
|
)
|
|
|
|
fork_info = None
|
|
if tree.parent_tree_id or tree.fork_depth > 0:
|
|
has_updates = False
|
|
if parent_tree and tree.parent_updated_at:
|
|
has_updates = parent_tree.updated_at > tree.parent_updated_at
|
|
fork_info = ForkInfo(
|
|
parent_tree_id=tree.parent_tree_id,
|
|
root_tree_id=tree.root_tree_id,
|
|
fork_reason=tree.fork_reason,
|
|
fork_depth=tree.fork_depth,
|
|
parent_updated_at=tree.parent_updated_at,
|
|
has_parent_updates=has_updates
|
|
)
|
|
|
|
return TreeResponse(
|
|
id=tree.id,
|
|
name=tree.name,
|
|
description=tree.description,
|
|
tree_type=tree.tree_type,
|
|
category=tree.category,
|
|
category_id=tree.category_id,
|
|
category_info=category_info,
|
|
tags=tree.tag_names,
|
|
fork_info=fork_info,
|
|
tree_structure=tree.tree_structure,
|
|
intake_form=tree.intake_form,
|
|
author_id=tree.author_id,
|
|
account_id=tree.account_id,
|
|
is_active=tree.is_active,
|
|
is_public=tree.is_public,
|
|
is_default=tree.is_default,
|
|
status=tree.status,
|
|
version=tree.version,
|
|
usage_count=tree.usage_count,
|
|
created_at=tree.created_at,
|
|
updated_at=tree.updated_at,
|
|
import_metadata=tree.import_metadata
|
|
)
|
|
|
|
|
|
@router.get("", response_model=list[TreeListResponse])
|
|
async def list_trees(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
tree_type: Optional[str] = Query(None, description="Filter by tree type: troubleshooting or procedural"),
|
|
category: Optional[str] = Query(None, description="Filter by legacy category string"),
|
|
category_id: Optional[UUID] = Query(None, description="Filter by category ID"),
|
|
tags: Optional[str] = Query(None, description="Comma-separated tag slugs to filter by"),
|
|
folder_id: Optional[UUID] = Query(None, description="Filter by folder ID (user's folders only)"),
|
|
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
|
author_id: Optional[UUID] = Query(None, description="Filter by author ID"),
|
|
is_public: Optional[bool] = Query(None, description="Filter by public status"),
|
|
visibility: Optional[str] = Query(None, description="Filter by visibility: private, team, link, public"),
|
|
sort_by: Optional[str] = Query("usage_count", description="Sort order: usage_count, updated_at, created_at, name, name_desc, version"),
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(100, ge=1, le=100)
|
|
):
|
|
"""List all trees with optional filters.
|
|
|
|
New filters:
|
|
- category_id: Filter by category (from tree_categories table)
|
|
- tags: Comma-separated tag slugs (e.g., "citrix,networking")
|
|
- folder_id: Show only trees in a specific folder
|
|
- sort_by: Sort order (usage_count [default], updated_at, created_at, name, name_desc, version)
|
|
"""
|
|
query = select(Tree).options(
|
|
selectinload(Tree.category_rel),
|
|
selectinload(Tree.tags)
|
|
)
|
|
|
|
# Apply filters
|
|
if tree_type:
|
|
query = query.where(Tree.tree_type == tree_type)
|
|
if category:
|
|
query = query.where(Tree.category == category)
|
|
if category_id:
|
|
query = query.where(Tree.category_id == category_id)
|
|
if is_active is not None:
|
|
query = query.where(Tree.is_active == is_active)
|
|
else:
|
|
# Default to only showing active trees
|
|
query = query.where(Tree.is_active == True)
|
|
if author_id:
|
|
query = query.where(Tree.author_id == author_id)
|
|
if is_public is not None:
|
|
query = query.where(Tree.is_public == is_public)
|
|
if visibility:
|
|
query = query.where(Tree.visibility == visibility)
|
|
|
|
# Filter by tags (all specified tags must be present)
|
|
if tags:
|
|
tag_slugs = [t.strip() for t in tags.split(",") if t.strip()]
|
|
for tag_slug in tag_slugs:
|
|
query = query.where(
|
|
Tree.tags.any(TreeTag.slug == tag_slug)
|
|
)
|
|
|
|
# Filter by folder
|
|
if folder_id:
|
|
# Verify folder belongs to user
|
|
folder_result = await db.execute(
|
|
select(UserFolder).where(
|
|
UserFolder.id == folder_id,
|
|
UserFolder.user_id == current_user.id
|
|
)
|
|
)
|
|
folder = folder_result.scalar_one_or_none()
|
|
if not folder:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Folder not found"
|
|
)
|
|
query = query.where(Tree.folders.any(UserFolder.id == folder_id))
|
|
|
|
# Apply access filter
|
|
query = query.where(build_tree_access_filter(current_user))
|
|
|
|
# Apply sorting
|
|
if sort_by == "updated_at":
|
|
query = query.order_by(Tree.updated_at.desc())
|
|
elif sort_by == "created_at":
|
|
query = query.order_by(Tree.created_at.desc())
|
|
elif sort_by == "name":
|
|
query = query.order_by(Tree.name.asc())
|
|
elif sort_by == "name_desc":
|
|
query = query.order_by(Tree.name.desc())
|
|
elif sort_by == "version":
|
|
query = query.order_by(Tree.version.desc(), Tree.updated_at.desc())
|
|
else: # Default to usage_count
|
|
query = query.order_by(Tree.usage_count.desc(), Tree.updated_at.desc())
|
|
|
|
query = query.offset(skip).limit(limit)
|
|
|
|
result = await db.execute(query)
|
|
trees = result.scalars().unique().all()
|
|
|
|
# Fetch author names in one query (avoids N+1)
|
|
author_ids = {t.author_id for t in trees if t.author_id}
|
|
author_map: dict = {}
|
|
if author_ids:
|
|
authors_result = await db.execute(
|
|
select(User.id, User.name, User.email).where(User.id.in_(author_ids))
|
|
)
|
|
for row in authors_result:
|
|
author_map[row.id] = row.name or row.email
|
|
|
|
return [build_tree_response(tree, author_map) for tree in trees]
|
|
|
|
|
|
@router.get("/categories", response_model=list[str])
|
|
async def list_categories(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
|
):
|
|
"""List all unique categories from trees the user can access.
|
|
|
|
Note: This returns legacy string categories. For the new category system,
|
|
use the /categories endpoint.
|
|
"""
|
|
query = select(Tree.category).where(
|
|
Tree.category.isnot(None),
|
|
Tree.is_active == True,
|
|
build_tree_access_filter(current_user)
|
|
).distinct()
|
|
result = await db.execute(query)
|
|
categories = [row[0] for row in result.all() if row[0]]
|
|
return sorted(categories)
|
|
|
|
|
|
# --- Pinned Flows Endpoints (must be before /{tree_id} to avoid route shadowing) ---
|
|
|
|
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.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))
|
|
|
|
|
|
@router.get("/search", response_model=list[TreeListResponse])
|
|
async def search_trees(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
q: str = Query(..., min_length=2, description="Search query"),
|
|
limit: int = Query(20, ge=1, le=50)
|
|
):
|
|
"""Full-text search trees by name and description."""
|
|
# Using PostgreSQL full-text search
|
|
search_vector = func.to_tsvector('english', func.coalesce(Tree.name, '') + ' ' + func.coalesce(Tree.description, ''))
|
|
search_query = func.plainto_tsquery('english', q)
|
|
|
|
query = select(Tree).options(
|
|
selectinload(Tree.category_rel),
|
|
selectinload(Tree.tags)
|
|
).where(
|
|
Tree.is_active == True,
|
|
build_tree_access_filter(current_user),
|
|
search_vector.op('@@')(search_query)
|
|
).order_by(
|
|
func.ts_rank(search_vector, search_query).desc()
|
|
).limit(limit)
|
|
|
|
result = await db.execute(query)
|
|
trees = result.scalars().unique().all()
|
|
|
|
return [build_tree_response(tree) for tree in trees]
|
|
|
|
|
|
@router.get("/{tree_id}", response_model=TreeResponse)
|
|
async def get_tree(
|
|
tree_id: UUID,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
|
):
|
|
"""Get a specific tree by ID."""
|
|
result = await db.execute(
|
|
select(Tree)
|
|
.options(
|
|
selectinload(Tree.category_rel),
|
|
selectinload(Tree.tags)
|
|
)
|
|
.where(Tree.id == tree_id)
|
|
)
|
|
tree = result.scalar_one_or_none()
|
|
|
|
if not tree:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Tree not found"
|
|
)
|
|
|
|
if not tree.is_active or not can_access_tree(current_user, tree):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You don't have access to this tree"
|
|
)
|
|
|
|
return build_full_tree_response(tree)
|
|
|
|
|
|
@router.post("", response_model=TreeResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_tree(
|
|
tree_data: TreeCreate,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
|
service_account_id: Annotated[Optional[UUID], Depends(get_service_account_id)],
|
|
):
|
|
"""Create a new tree (engineers and admins only).
|
|
|
|
Supports:
|
|
- category_id: Assign to a category from tree_categories
|
|
- tags: List of tag names to assign (creates new tags if needed)
|
|
- status: draft or published (published requires validation)
|
|
"""
|
|
# Validate tree if status is 'published'
|
|
if tree_data.status == 'published':
|
|
# Convert intake_form to dicts for validation
|
|
intake_form_dicts = None
|
|
if tree_data.intake_form:
|
|
intake_form_dicts = [f.model_dump() for f in tree_data.intake_form]
|
|
can_publish, validation_errors = can_publish_tree(
|
|
tree_data.tree_structure,
|
|
tree_data.name,
|
|
tree_data.description,
|
|
tree_type=tree_data.tree_type,
|
|
intake_form=intake_form_dicts,
|
|
)
|
|
if not can_publish:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail={
|
|
"message": "Cannot publish tree with validation errors",
|
|
"errors": validation_errors
|
|
}
|
|
)
|
|
|
|
# Only admins can create default/system trees
|
|
is_default = tree_data.is_default and current_user.is_super_admin
|
|
|
|
# Verify category exists if provided
|
|
if tree_data.category_id:
|
|
cat_result = await db.execute(
|
|
select(TreeCategory).where(TreeCategory.id == tree_data.category_id)
|
|
)
|
|
category = cat_result.scalar_one_or_none()
|
|
if not category:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Category not found"
|
|
)
|
|
# Check category access
|
|
if category.account_id and category.account_id != current_user.account_id and not current_user.is_super_admin:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You don't have access to this category"
|
|
)
|
|
|
|
# Convert intake_form Pydantic models to dicts for JSONB storage
|
|
intake_form_data = None
|
|
if tree_data.intake_form:
|
|
intake_form_data = [f.model_dump(exclude_none=True) for f in tree_data.intake_form]
|
|
|
|
new_tree = Tree(
|
|
name=tree_data.name,
|
|
description=tree_data.description,
|
|
category=tree_data.category,
|
|
category_id=tree_data.category_id,
|
|
tree_type=tree_data.tree_type,
|
|
tree_structure=tree_data.tree_structure,
|
|
intake_form=intake_form_data,
|
|
author_id=service_account_id if is_default else current_user.id,
|
|
account_id=None if is_default else current_user.account_id,
|
|
is_public=True if is_default else tree_data.is_public, # Default trees are always public
|
|
is_default=is_default,
|
|
status=tree_data.status
|
|
)
|
|
# Check subscription tree limit
|
|
if not is_default and current_user.account_id:
|
|
can_create, limit, count = await check_tree_limit(current_user.account_id, db)
|
|
if not can_create:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
detail=f"Tree limit reached ({count}/{limit}). Upgrade your plan to create more trees."
|
|
)
|
|
|
|
db.add(new_tree)
|
|
await db.flush() # Get the ID
|
|
|
|
# Handle tags
|
|
if tree_data.tags:
|
|
tree_account_id = new_tree.account_id or (current_user.account_id if not current_user.is_super_admin else None)
|
|
|
|
# Collect tags to add
|
|
tags_to_add = []
|
|
for tag_name in tree_data.tags:
|
|
slug = TreeTag.slugify(tag_name)
|
|
|
|
# Try to find existing tag
|
|
tag_query = select(TreeTag).where(
|
|
TreeTag.slug == slug,
|
|
or_(
|
|
TreeTag.account_id.is_(None),
|
|
TreeTag.account_id == tree_account_id
|
|
)
|
|
)
|
|
tag_result = await db.execute(tag_query)
|
|
tag = tag_result.scalar_one_or_none()
|
|
|
|
if not tag:
|
|
# Create new tag
|
|
tag = TreeTag(
|
|
name=tag_name,
|
|
slug=slug,
|
|
account_id=tree_account_id,
|
|
created_by=current_user.id
|
|
)
|
|
db.add(tag)
|
|
await db.flush()
|
|
|
|
tags_to_add.append(tag)
|
|
tag.usage_count += 1
|
|
|
|
# Use direct SQL insert for the junction table to avoid lazy load issues
|
|
from app.models.tag import tree_tag_assignments
|
|
for tag in tags_to_add:
|
|
await db.execute(
|
|
tree_tag_assignments.insert().values(
|
|
tree_id=new_tree.id,
|
|
tag_id=tag.id,
|
|
assigned_by=current_user.id
|
|
)
|
|
)
|
|
|
|
await db.commit()
|
|
|
|
# Reload with relationships
|
|
result = await db.execute(
|
|
select(Tree)
|
|
.options(
|
|
selectinload(Tree.category_rel),
|
|
selectinload(Tree.tags)
|
|
)
|
|
.where(Tree.id == new_tree.id)
|
|
)
|
|
tree = result.scalar_one()
|
|
|
|
# Index tree for RAG (best-effort, don't fail the request)
|
|
try:
|
|
await rag_index_tree(tree.id, db)
|
|
await db.commit()
|
|
except Exception:
|
|
logging.getLogger(__name__).warning("RAG indexing failed for tree %s", tree.id)
|
|
|
|
return build_full_tree_response(tree)
|
|
|
|
|
|
@router.put("/{tree_id}", response_model=TreeResponse)
|
|
async def update_tree(
|
|
tree_id: UUID,
|
|
tree_data: TreeUpdate,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
|
service_account_id: Annotated[Optional[UUID], Depends(get_service_account_id)],
|
|
):
|
|
"""Update an existing tree (engineers and admins only).
|
|
|
|
Supports:
|
|
- category_id: Change category assignment
|
|
- tags: Replace all tags on the tree
|
|
- status: Update status (requires validation when publishing)
|
|
"""
|
|
result = await db.execute(
|
|
select(Tree)
|
|
.options(
|
|
selectinload(Tree.category_rel),
|
|
selectinload(Tree.tags)
|
|
)
|
|
.where(Tree.id == tree_id)
|
|
)
|
|
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_edit_tree(current_user, tree):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You can only edit your own trees"
|
|
)
|
|
|
|
# Extract tags for separate handling
|
|
update_data = tree_data.model_dump(exclude_unset=True)
|
|
tags_data = update_data.pop("tags", None)
|
|
|
|
# Validate if transitioning to published status
|
|
if "status" in update_data and update_data["status"] == 'published':
|
|
# Get the final tree structure and name after update
|
|
final_tree_structure = update_data.get("tree_structure", tree.tree_structure)
|
|
final_name = update_data.get("name", tree.name)
|
|
final_description = update_data.get("description", tree.description)
|
|
final_tree_type = update_data.get("tree_type", tree.tree_type)
|
|
final_intake_form = update_data.get("intake_form", tree.intake_form)
|
|
|
|
can_publish, validation_errors = can_publish_tree(
|
|
final_tree_structure,
|
|
final_name,
|
|
final_description,
|
|
tree_type=final_tree_type,
|
|
intake_form=final_intake_form,
|
|
)
|
|
if not can_publish:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail={
|
|
"message": "Cannot publish tree with validation errors",
|
|
"errors": validation_errors
|
|
}
|
|
)
|
|
|
|
# Verify new category if provided
|
|
if "category_id" in update_data and update_data["category_id"]:
|
|
cat_result = await db.execute(
|
|
select(TreeCategory).where(TreeCategory.id == update_data["category_id"])
|
|
)
|
|
category = cat_result.scalar_one_or_none()
|
|
if not category:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Category not found"
|
|
)
|
|
if category.account_id and category.account_id != current_user.account_id and not current_user.is_super_admin:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You don't have access to this category"
|
|
)
|
|
|
|
# Update basic fields
|
|
for field, value in update_data.items():
|
|
setattr(tree, field, value)
|
|
|
|
# Keep visibility and is_public in sync
|
|
if tree_data.is_public is not None:
|
|
if tree_data.is_public and tree.visibility not in ('public',):
|
|
tree.visibility = 'public'
|
|
elif not tree_data.is_public and tree.visibility == 'public':
|
|
tree.visibility = 'team' # downgrade from public to team
|
|
|
|
# Increment version if tree structure changed
|
|
if "tree_structure" in update_data:
|
|
tree.version += 1
|
|
|
|
# Sync steps to step library on publish transition only
|
|
if update_data.get("status") == 'published':
|
|
_structure = update_data.get("tree_structure", tree.tree_structure)
|
|
_type = update_data.get("tree_type", tree.tree_type)
|
|
_is_public = update_data.get("is_public", tree.is_public)
|
|
await sync_steps_from_tree(
|
|
db=db,
|
|
tree_id=tree.id,
|
|
tree_type=_type,
|
|
tree_structure=_structure,
|
|
author_id=tree.author_id,
|
|
account_id=tree.account_id,
|
|
is_public=_is_public,
|
|
service_account_id=service_account_id,
|
|
)
|
|
|
|
# Handle tags replacement
|
|
if tags_data is not None:
|
|
from app.models.tag import tree_tag_assignments
|
|
|
|
# Decrement usage count for old tags (already eagerly loaded)
|
|
for tag in tree.tags:
|
|
tag.usage_count = max(0, tag.usage_count - 1)
|
|
|
|
# Delete existing tag assignments using direct SQL
|
|
await db.execute(
|
|
tree_tag_assignments.delete().where(
|
|
tree_tag_assignments.c.tree_id == tree.id
|
|
)
|
|
)
|
|
|
|
# Add new tags
|
|
tree_account_id = tree.account_id or (current_user.account_id if not current_user.is_super_admin else None)
|
|
added_tag_ids = set()
|
|
|
|
for tag_name in tags_data:
|
|
slug = TreeTag.slugify(tag_name)
|
|
|
|
tag_query = select(TreeTag).where(
|
|
TreeTag.slug == slug,
|
|
or_(
|
|
TreeTag.account_id.is_(None),
|
|
TreeTag.account_id == tree_account_id
|
|
)
|
|
)
|
|
tag_result = await db.execute(tag_query)
|
|
tag = tag_result.scalar_one_or_none()
|
|
|
|
if not tag:
|
|
tag = TreeTag(
|
|
name=tag_name,
|
|
slug=slug,
|
|
account_id=tree_account_id,
|
|
created_by=current_user.id
|
|
)
|
|
db.add(tag)
|
|
await db.flush()
|
|
|
|
if tag.id not in added_tag_ids:
|
|
await db.execute(
|
|
tree_tag_assignments.insert().values(
|
|
tree_id=tree.id,
|
|
tag_id=tag.id,
|
|
assigned_by=current_user.id
|
|
)
|
|
)
|
|
added_tag_ids.add(tag.id)
|
|
tag.usage_count += 1
|
|
|
|
await db.commit()
|
|
|
|
# Reload with relationships
|
|
result = await db.execute(
|
|
select(Tree)
|
|
.options(
|
|
selectinload(Tree.category_rel),
|
|
selectinload(Tree.tags)
|
|
)
|
|
.where(Tree.id == tree_id)
|
|
)
|
|
tree = result.scalar_one()
|
|
|
|
# Re-index tree for RAG (best-effort)
|
|
try:
|
|
await rag_index_tree(tree.id, db)
|
|
await db.commit()
|
|
except Exception:
|
|
logging.getLogger(__name__).warning("RAG re-indexing failed for tree %s", tree.id)
|
|
|
|
return build_full_tree_response(tree)
|
|
|
|
|
|
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_tree(
|
|
tree_id: UUID,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(require_admin)]
|
|
):
|
|
"""Soft delete a tree (admin only). Sets deleted_at timestamp and is_active=False."""
|
|
result = await db.execute(select(Tree).where(Tree.id == tree_id))
|
|
tree = result.scalar_one_or_none()
|
|
|
|
if not tree:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Tree not found"
|
|
)
|
|
|
|
tree.is_active = False
|
|
tree.deleted_at = datetime.now(timezone.utc)
|
|
tree.deleted_by = current_user.id
|
|
|
|
# Clean up folder assignments
|
|
await db.execute(
|
|
user_folder_trees.delete().where(user_folder_trees.c.tree_id == tree.id)
|
|
)
|
|
|
|
# Decrement usage_count on associated tags (floor at 0)
|
|
tag_ids_result = await db.execute(
|
|
select(tree_tag_assignments.c.tag_id).where(
|
|
tree_tag_assignments.c.tree_id == tree.id
|
|
)
|
|
)
|
|
tag_ids = [row[0] for row in tag_ids_result.fetchall()]
|
|
if tag_ids:
|
|
await db.execute(
|
|
update(TreeTag)
|
|
.where(TreeTag.id.in_(tag_ids))
|
|
.values(usage_count=func.greatest(TreeTag.usage_count - 1, 0))
|
|
)
|
|
|
|
# Clean up tag assignments
|
|
await db.execute(
|
|
tree_tag_assignments.delete().where(tree_tag_assignments.c.tree_id == tree.id)
|
|
)
|
|
|
|
# Deactivate any synced step library entries before deletion
|
|
# (must happen before db.delete/commit — FK SET NULL would lose the reference)
|
|
await deactivate_synced_steps_for_tree(db, tree.id)
|
|
|
|
await log_audit(db, current_user.id, "tree.delete", "tree", tree.id,
|
|
{"tree_name": tree.name})
|
|
await db.commit()
|
|
return None
|
|
|
|
|
|
# --- Fork Endpoints ---
|
|
|
|
|
|
@router.post("/{tree_id}/fork", response_model=TreeResponse, status_code=status.HTTP_201_CREATED)
|
|
async def fork_tree(
|
|
tree_id: UUID,
|
|
fork_data: ForkCreate,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(require_engineer_or_admin)]
|
|
):
|
|
"""Fork a tree to create a personal copy.
|
|
|
|
Engineers can fork any tree they can access (public, account, or default).
|
|
Fork inherits tree_structure but gets new ownership.
|
|
"""
|
|
# Load parent tree
|
|
result = await db.execute(
|
|
select(Tree)
|
|
.options(selectinload(Tree.category_rel), selectinload(Tree.tags))
|
|
.where(Tree.id == tree_id)
|
|
)
|
|
parent = result.scalar_one_or_none()
|
|
|
|
if not parent:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Tree not found"
|
|
)
|
|
|
|
if not can_access_tree(current_user, parent):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You don't have access to this tree"
|
|
)
|
|
|
|
# Check subscription tree limit
|
|
if current_user.account_id:
|
|
can_create, limit, count = await check_tree_limit(current_user.account_id, db)
|
|
if not can_create:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
detail=f"Tree limit reached ({count}/{limit}). Upgrade your plan to create more trees."
|
|
)
|
|
|
|
# Build fork
|
|
fork_name = fork_data.name or f"Fork of {parent.name}"
|
|
fork = Tree(
|
|
name=fork_name,
|
|
description=parent.description,
|
|
category=parent.category,
|
|
category_id=parent.category_id,
|
|
tree_type=parent.tree_type,
|
|
tree_structure=parent.tree_structure,
|
|
intake_form=parent.intake_form,
|
|
author_id=current_user.id,
|
|
account_id=current_user.account_id,
|
|
is_public=False,
|
|
is_default=False,
|
|
version=1,
|
|
# Fork tracking
|
|
parent_tree_id=parent.id,
|
|
fork_reason=fork_data.fork_reason,
|
|
parent_updated_at=parent.updated_at,
|
|
# Lineage tracking
|
|
root_tree_id=parent.root_tree_id if parent.root_tree_id else parent.id,
|
|
fork_depth=parent.fork_depth + 1,
|
|
)
|
|
|
|
db.add(fork)
|
|
await db.flush()
|
|
|
|
await log_audit(db, current_user.id, "tree.fork", "tree", fork.id,
|
|
{"parent_tree_id": str(parent.id), "parent_name": parent.name,
|
|
"fork_reason": fork_data.fork_reason})
|
|
await db.commit()
|
|
|
|
# Reload with relationships
|
|
result = await db.execute(
|
|
select(Tree)
|
|
.options(selectinload(Tree.category_rel), selectinload(Tree.tags))
|
|
.where(Tree.id == fork.id)
|
|
)
|
|
fork = result.scalar_one()
|
|
|
|
return build_full_tree_response(fork, parent_tree=parent)
|
|
|
|
|
|
@router.get("/{tree_id}/forks", response_model=list[TreeListResponse])
|
|
async def list_forks(
|
|
tree_id: UUID,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(50, ge=1, le=100)
|
|
):
|
|
"""List all direct forks of a tree."""
|
|
# Verify parent exists and user can access it
|
|
parent_result = await db.execute(select(Tree).where(Tree.id == tree_id))
|
|
parent = parent_result.scalar_one_or_none()
|
|
|
|
if not parent:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Tree not found"
|
|
)
|
|
|
|
if not can_access_tree(current_user, parent):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You don't have access to this tree"
|
|
)
|
|
|
|
# Query direct forks, filtered by access
|
|
query = select(Tree).options(
|
|
selectinload(Tree.category_rel),
|
|
selectinload(Tree.tags)
|
|
).where(
|
|
Tree.parent_tree_id == tree_id,
|
|
Tree.is_active == True,
|
|
build_tree_access_filter(current_user)
|
|
).order_by(Tree.created_at.desc()).offset(skip).limit(limit)
|
|
|
|
result = await db.execute(query)
|
|
forks = result.scalars().unique().all()
|
|
|
|
return [build_tree_response(tree) for tree in forks]
|
|
|
|
|
|
@router.get("/{tree_id}/lineage", response_model=list[TreeListResponse])
|
|
async def get_tree_lineage(
|
|
tree_id: UUID,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
|
):
|
|
"""Get the fork lineage chain from current tree back to root.
|
|
|
|
Returns ordered list: [current tree, parent, grandparent, ..., root]
|
|
Limited to 10 levels to prevent infinite loops.
|
|
"""
|
|
lineage = []
|
|
current_id = tree_id
|
|
visited = set()
|
|
max_depth = 10
|
|
|
|
for _ in range(max_depth):
|
|
if current_id is None or current_id in visited:
|
|
break
|
|
visited.add(current_id)
|
|
|
|
result = await db.execute(
|
|
select(Tree)
|
|
.options(selectinload(Tree.category_rel), selectinload(Tree.tags))
|
|
.where(Tree.id == current_id)
|
|
)
|
|
tree = result.scalar_one_or_none()
|
|
|
|
if not tree:
|
|
break
|
|
|
|
lineage.append(build_tree_response(tree))
|
|
current_id = tree.parent_tree_id
|
|
|
|
return lineage
|
|
|
|
|
|
# --- Tree Sharing Endpoints ---
|
|
|
|
|
|
@router.post("/{tree_id}/share", response_model=TreeShareResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_tree_share(
|
|
tree_id: UUID,
|
|
share_data: TreeShareCreate,
|
|
request: Request,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
|
):
|
|
"""Generate a share token for a tree.
|
|
|
|
Requirements:
|
|
- Tree author can always create shares
|
|
- Account members can share trees with visibility 'team', 'link', or 'public'
|
|
- Super admins can share any tree
|
|
"""
|
|
# Load tree
|
|
result = await db.execute(
|
|
select(Tree).where(Tree.id == tree_id, Tree.is_active == True)
|
|
)
|
|
tree = result.scalar_one_or_none()
|
|
|
|
if not tree:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Tree not found"
|
|
)
|
|
|
|
# Check permissions
|
|
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"
|
|
)
|
|
|
|
# Generate unique share token
|
|
share_token = secrets.token_urlsafe(48) # 48 bytes -> 64 base64 chars
|
|
|
|
# Create share
|
|
tree_share = TreeShare(
|
|
tree_id=tree.id,
|
|
share_token=share_token,
|
|
created_by=current_user.id,
|
|
allow_forking=share_data.allow_forking,
|
|
expires_at=share_data.expires_at
|
|
)
|
|
|
|
db.add(tree_share)
|
|
await log_audit(db, current_user.id, "tree.share.create", "tree_share", tree_share.id,
|
|
{"tree_id": str(tree.id), "tree_name": tree.name, "allow_forking": share_data.allow_forking})
|
|
await db.commit()
|
|
await db.refresh(tree_share)
|
|
|
|
# Build share URL
|
|
base_url = str(request.base_url).rstrip('/')
|
|
share_url = f"{base_url}/shared/{share_token}"
|
|
|
|
return TreeShareResponse(
|
|
id=tree_share.id,
|
|
tree_id=tree_share.tree_id,
|
|
share_token=tree_share.share_token,
|
|
share_url=share_url,
|
|
allow_forking=tree_share.allow_forking,
|
|
created_by=tree_share.created_by,
|
|
created_at=tree_share.created_at,
|
|
expires_at=tree_share.expires_at
|
|
)
|
|
|
|
|
|
@router.get("/{tree_id}/shares", response_model=list[TreeShareResponse])
|
|
async def list_tree_shares(
|
|
tree_id: UUID,
|
|
request: Request,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
|
):
|
|
"""List all active shares for a tree."""
|
|
# Verify tree exists and user can access it
|
|
result = await db.execute(
|
|
select(Tree).where(Tree.id == tree_id)
|
|
)
|
|
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"
|
|
)
|
|
|
|
# Query shares
|
|
shares_result = await db.execute(
|
|
select(TreeShare)
|
|
.where(TreeShare.tree_id == tree_id)
|
|
.order_by(TreeShare.created_at.desc())
|
|
)
|
|
shares = shares_result.scalars().all()
|
|
|
|
# Build responses with share URLs
|
|
base_url = str(request.base_url).rstrip('/')
|
|
return [
|
|
TreeShareResponse(
|
|
id=share.id,
|
|
tree_id=share.tree_id,
|
|
share_token=share.share_token,
|
|
share_url=f"{base_url}/shared/{share.share_token}",
|
|
allow_forking=share.allow_forking,
|
|
created_by=share.created_by,
|
|
created_at=share.created_at,
|
|
expires_at=share.expires_at
|
|
)
|
|
for share in shares
|
|
]
|
|
|
|
|
|
@router.patch("/{tree_id}/visibility", response_model=TreeResponse)
|
|
async def update_tree_visibility(
|
|
tree_id: UUID,
|
|
visibility_data: TreeVisibilityUpdate,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(require_engineer_or_admin)]
|
|
):
|
|
"""Update tree visibility level.
|
|
|
|
Visibility levels:
|
|
- private: Only tree author can access
|
|
- team: Account members can access
|
|
- link: Anyone with a valid share token can access
|
|
- public: All authenticated users can access
|
|
"""
|
|
result = await db.execute(
|
|
select(Tree).where(Tree.id == tree_id)
|
|
)
|
|
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_edit_tree(current_user, tree):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You can only edit your own trees"
|
|
)
|
|
|
|
# Update visibility
|
|
old_visibility = tree.visibility
|
|
tree.visibility = visibility_data.visibility
|
|
tree.is_public = (visibility_data.visibility == 'public')
|
|
|
|
await log_audit(db, current_user.id, "tree.visibility.update", "tree", tree.id,
|
|
{"tree_name": tree.name, "old_visibility": old_visibility,
|
|
"new_visibility": visibility_data.visibility})
|
|
await db.commit()
|
|
|
|
# Reload with relationships
|
|
result = await db.execute(
|
|
select(Tree)
|
|
.options(
|
|
selectinload(Tree.category_rel),
|
|
selectinload(Tree.tags)
|
|
)
|
|
.where(Tree.id == tree_id)
|
|
)
|
|
tree = result.scalar_one()
|
|
|
|
return build_full_tree_response(tree)
|
|
|
|
# --- Tree Validation Endpoint ---
|
|
|
|
|
|
@router.post("/{tree_id}/can-publish", response_model=TreeValidationResponse)
|
|
async def check_tree_can_publish(
|
|
tree_id: UUID,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
|
):
|
|
"""Check if a tree can be published (validation endpoint).
|
|
|
|
Returns validation status and any errors that would prevent publishing.
|
|
Useful for providing real-time feedback in the UI without attempting to publish.
|
|
"""
|
|
result = await db.execute(
|
|
select(Tree).where(Tree.id == tree_id)
|
|
)
|
|
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"
|
|
)
|
|
|
|
# Validate the tree
|
|
can_publish, validation_errors = can_publish_tree(
|
|
tree.tree_structure,
|
|
tree.name,
|
|
tree.description,
|
|
tree_type=tree.tree_type,
|
|
intake_form=tree.intake_form,
|
|
)
|
|
|
|
return TreeValidationResponse(
|
|
can_publish=can_publish,
|
|
errors=[ValidationError(**error) for error in validation_errors]
|
|
)
|
|
|
|
|
|
@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}
|