feat: add procedural flows with intake forms, navigation, and seed templates

Adds a new "procedural" tree type for linear step-by-step project workflows
(domain controller setup, M365 onboarding, VPN config, etc). Includes intake
form builder, two-panel step navigation, variable resolution, procedural
exports, 3 seed templates, and UI rename from "Trees" to "Flows".

Also archives 19 implemented plan docs and creates deferred features backlog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-14 04:13:52 -05:00
parent 303570ca2c
commit 350c977eda
58 changed files with 11686 additions and 167 deletions

View File

@@ -136,13 +136,27 @@ async def start_session(
detail="You don't have access to this tree"
)
# For procedural trees with intake forms, validate required fields
session_variables = session_data.session_variables or {}
if tree.tree_type == 'procedural' and tree.intake_form:
missing_fields = []
for field in tree.intake_form:
if field.get("required") and not session_variables.get(field["variable_name"]):
missing_fields.append(field["label"])
if missing_fields:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Missing required intake form fields: {', '.join(missing_fields)}"
)
# Create session with tree snapshot (includes tree metadata for filtering/export)
tree_snapshot = {
**tree.tree_structure,
"name": tree.name,
"description": tree.description,
"category": tree.category,
"version": tree.version
"version": tree.version,
"tree_type": tree.tree_type,
}
new_session = Session(
@@ -152,7 +166,8 @@ async def start_session(
path_taken=[],
decisions=[],
ticket_number=session_data.ticket_number,
client_name=session_data.client_name
client_name=session_data.client_name,
session_variables=session_variables,
)
# Increment tree usage count
@@ -421,7 +436,9 @@ async def save_session_as_tree(
can_publish, validation_errors = can_publish_tree(
tree_structure,
tree_name,
request_data.description
request_data.description,
tree_type=original_tree.tree_type,
intake_form=original_tree.intake_form,
)
if not can_publish:
raise HTTPException(

View File

@@ -44,6 +44,7 @@ def build_tree_response(tree: Tree) -> 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,
@@ -89,12 +90,14 @@ def build_full_tree_response(tree: Tree, parent_tree: Tree = None) -> TreeRespon
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,
@@ -112,6 +115,7 @@ def build_full_tree_response(tree: Tree, parent_tree: Tree = None) -> TreeRespon
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"),
@@ -137,6 +141,8 @@ async def list_trees(
)
# Apply filters
if tree_type:
query = query.where(Tree.tree_type == tree_type)
if category:
query = query.where(Tree.category == category)
if category_id:
@@ -297,10 +303,16 @@ async def create_tree(
"""
# 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_data.description,
tree_type=tree_data.tree_type,
intake_form=intake_form_dicts,
)
if not can_publish:
raise HTTPException(
@@ -332,12 +344,19 @@ async def create_tree(
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=None if is_default else current_user.id, # Default trees have no author
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
@@ -463,11 +482,15 @@ async def update_tree(
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
final_description,
tree_type=final_tree_type,
intake_form=final_intake_form,
)
if not can_publish:
raise HTTPException(
@@ -673,7 +696,9 @@ async def fork_tree(
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,
@@ -996,7 +1021,9 @@ async def check_tree_can_publish(
can_publish, validation_errors = can_publish_tree(
tree.tree_structure,
tree.name,
tree.description
tree.description,
tree_type=tree.tree_type,
intake_form=tree.intake_form,
)
return TreeValidationResponse(