Files
resolutionflow/backend/app/api/endpoints/sessions.py
chihlasm b3dba57bc5 feat: tenant isolation Phase 0 — app-layer filters, UUID audit, CI gate (#132)
* docs: add tenant data isolation design spec

Complete architecture plan for multi-tenant data isolation across
all layers (PostgreSQL RLS, application-layer filtering, schema
migration, testing strategy, and phased rollout checklist).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add background job isolation policy to tenant isolation spec

Documents policy for all 5 existing background jobs:
- Knowledge Flywheel and PSA Retry flagged for account_id threading
- Chat Retention already follows correct pattern (model for others)
- Maintenance Schedule Firing needs account_id in queries + Session creation
- AI Conversation Expiry approved as cross-tenant with justification

Adds approved cross-tenant query registry and Phase 2 checklist items.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add tenant isolation Phase 0 implementation plan

8 tasks covering: CRITICAL copilot hotfix, tenant_filter() helper,
get_tenant_context dependency, analytics/category/AI session gap fixes,
full UUID endpoint audit, TargetList dead code audit, teams orphan
check, and CI grep check for missing tenant filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add tenant_filter() helper and get_tenant_context dependency

tenant_filter(model, account_id) is the canonical app-layer tenant
scoping expression. Every query on a tenant table must use it.
build_tree_access_filter and build_step_visibility_filter updated
to call tenant_filter() internally for the account_id match.

get_tenant_context is a FastAPI dependency that returns account_id
or raises 403 if the user has no account — prevents raw access to
current_user.account_id and centralises the null check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: scope analytics/flows/{tree_id} to requesting account

Any authenticated user could read flow analytics (session counts,
completion rates, CSAT) for any tree UUID. Now returns 404 if the
tree doesn't belong to the requesting account.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: scope category tree_count to requesting account

tree_count on GET /categories/{id} was including trees from all
accounts, leaking cross-tenant row counts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: restrict AI session search to current user only

Search endpoint used OR(user_id, account_id), exposing other users'
problem_summary and problem_domain within the same account. Sessions
are user-scoped only — cross-user access requires explicit escalation
or sharing. List and search endpoints now behave consistently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add ownership check and 404 responses to ai-sessions endpoints

Cross-tenant isolation audit found:
- retry-psa-push had NO ownership check (CRITICAL) — any user could retry any session's PSA push
- save_task_lane used db.get() without ownership filter, returned 403 revealing existence
- get_session returned 403 instead of 404 for unauthorized access
- stream_documentation returned 403 instead of 404

All now use query-level user_id filtering and return 404 to avoid revealing existence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-tenant session access

All session endpoints (get, update, complete, scratchpad, variables, export,
ticket-link) now return 404 instead of 403 when a user tries to access
another user's session. This prevents confirming existence of resources
across tenant boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-tenant tree access

get_tree and update_tree now return 404 when a user cannot access a tree
(private tree from another account). Prevents confirming resource existence
across tenant boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-tenant step access

get_step_or_404 now returns 404 when can_view_step or can_edit_step fails,
preventing confirmation of step existence across tenant boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-tenant upload access

get_upload_url and delete_upload now return 404 when the upload belongs to
a different account/user, preventing resource existence confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-tenant share access

revoke_share and create_share now return 404 when the caller is not the
owner, preventing resource existence confirmation across users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-team tree access in maintenance schedules

_get_tree_or_403 now returns 404 when the user's team does not match,
preventing confirmation of tree existence across teams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-account tag access

get_tag now returns 404 for account-specific tags that belong to another
account, preventing resource existence confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 instead of 403 for cross-account step category access

get_step_category now returns 404 for account-specific categories that
belong to another account, preventing resource existence confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: add cross-tenant isolation tests for Task 6 UUID audit

Tests cover:
- Tree GET/PUT returns 404 for cross-account access
- Session GET returns 404 for cross-user access
- AI session GET returns 404 for cross-user access
- AI session retry-psa-push requires ownership
- Upload URL returns 404 for cross-account access
- Share revoke returns 404 for cross-user access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: return 404 (not 403) for get_documentation cross-user access; add missing Task 6 tests

get_documentation was revealing session existence via 403. Added pre-check
query filtering by session_id AND user_id before calling the engine.

Also add cross-tenant isolation tests for steps, tags, step_categories,
and maintenance_schedules endpoints fixed in Task 6 (TDD was skipped).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address Task 6 quality review — rename helper, restore 403 for intra-account, add docs test

- Rename _get_tree_or_403 → _get_tree_or_404 in maintenance_schedules.py
  (function now raises 404, old name was misleading)
- Restore HTTP 403 for intra-account permission failures in update_tree:
  same-account users who can see a tree but can't edit it got 404 (wrong);
  only cross-account lookups should return 404 to avoid confirming existence
- Apply same 403/404 distinction to update_tree_visibility
- Add test: get_documentation must return 404 for cross-user session access
- Add comment documenting owner-only design for documentation endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: Task 7+8 — TargetList audit, CI tenant-filter grep check

Task 7: TargetList dead code audit
- Found active code references in 12+ files across backend and frontend
  (full CRUD API + frontend page + MaintenanceScheduleSection + BatchLaunchModal)
- Decision: migrate to account_id in Phase 1 (cannot drop)
- DB row count not available from code-server — must verify from VPS SSH
  before Phase 1 migration
- Teams orphan check query documented; must run from VPS SSH before Phase 1
- Results documented in spec Section 9

Task 8: CI tenant-filter enforcement check (warn mode)
- Create backend/scripts/check_tenant_filters.py
  Scans endpoint and service files for select() on tenant tables without
  tenant_filter/account_id/user_id in surrounding context. Currently
  reports 109 warnings (Phase 1 backlog). Exits 0 (warn mode).
- Add Check tenant filter enforcement step to backend CI job
  Add --fail flag after Phase 1 backlog clears to make it blocking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: record Phase 0 audit results — 0 orphaned teams, 0 target_list rows

Both checks confirmed 2026-04-09 from production DB.
Phase 1 migration is safe to proceed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 00:42:19 -04:00

1182 lines
42 KiB
Python

from datetime import datetime, timezone
from typing import Annotated, Optional
from uuid import UUID
import uuid
from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel, Field as PydanticField
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update as sa_update
from app.core.database import get_db
from app.models.tree import Tree
from app.models.session import Session
from app.models.user import User
from app.schemas.session import (
SessionCreate,
SessionUpdate,
SessionResponse,
SessionExport,
ScratchpadUpdate,
SaveAsTreeRequest,
SaveAsTreeResponse,
SessionComplete,
SessionVariablesUpdate,
PrepareSessionRequest,
TicketLinkRequest,
TicketLinkResponse,
PSATicketResponse,
)
from app.schemas.psa_connection import PsaPostRequest
from app.api.deps import get_current_active_user, require_engineer_or_admin
from app.core.permissions import can_access_tree
from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export, generate_psa_export
router = APIRouter(prefix="/sessions", tags=["sessions"])
@router.get("", response_model=list[SessionResponse])
async def list_sessions(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
completed: Optional[bool] = Query(None, description="Filter by completion status"),
ticket_number: Optional[str] = Query(None, description="Search by ticket number (partial match)"),
client_name: Optional[str] = Query(None, description="Search by client name (partial match)"),
tree_name: Optional[str] = Query(None, description="Filter by tree name from snapshot"),
tree_id: Optional[UUID] = Query(None, description="Filter by tree ID"),
batch_id: Optional[UUID] = Query(None, description="Filter by batch ID (maintenance batch runs)"),
assigned_to_id: Optional[UUID] = Query(None, description="Filter by assigned user ID (prepared sessions)"),
status: Optional[str] = Query(None, description="Filter by status: prepared, active, completed"),
started_after: Optional[datetime] = Query(None, description="Filter sessions started after this datetime"),
started_before: Optional[datetime] = Query(None, description="Filter sessions started before this datetime"),
completed_after: Optional[datetime] = Query(None, description="Filter sessions completed after this datetime"),
completed_before: Optional[datetime] = Query(None, description="Filter sessions completed before this datetime"),
page: Optional[int] = Query(None, ge=1, description="1-based page number (frontend compatibility)"),
size: Optional[int] = Query(None, ge=1, le=100, description="Page size (frontend compatibility)"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100)
):
"""List user's troubleshooting sessions with comprehensive filtering."""
from sqlalchemy import or_
# Show sessions owned by user OR assigned to user (prepared sessions)
query = select(Session).where(
or_(Session.user_id == current_user.id, Session.assigned_to_id == current_user.id)
)
# Completion status filter
if completed is not None:
if completed:
query = query.where(Session.completed_at.isnot(None))
else:
query = query.where(Session.completed_at.is_(None))
# Ticket number search (case-insensitive partial match)
if ticket_number:
query = query.where(Session.ticket_number.ilike(f"%{ticket_number}%"))
# Client name search (case-insensitive partial match)
if client_name:
query = query.where(Session.client_name.ilike(f"%{client_name}%"))
# Tree name filter (JSONB path query)
if tree_name:
query = query.where(Session.tree_snapshot['name'].astext.ilike(f"%{tree_name}%"))
# Tree ID filter
if tree_id:
query = query.where(Session.tree_id == tree_id)
# Batch ID filter
if batch_id:
query = query.where(Session.batch_id == batch_id)
# Assigned user filter (for prepared sessions)
if assigned_to_id:
query = query.where(Session.assigned_to_id == assigned_to_id)
# Status filter: prepared (started_at IS NULL), active, completed
if status == "prepared":
query = query.where(Session.started_at.is_(None))
elif status == "active":
query = query.where(Session.started_at.isnot(None), Session.completed_at.is_(None))
elif status == "completed":
query = query.where(Session.completed_at.isnot(None))
# Date range filters
if started_after:
query = query.where(Session.started_at >= started_after)
if started_before:
query = query.where(Session.started_at <= started_before)
if completed_after:
query = query.where(Session.completed_at >= completed_after)
if completed_before:
query = query.where(Session.completed_at <= completed_before)
effective_limit = size if size is not None else limit
effective_skip = skip
if page is not None:
effective_skip = (page - 1) * effective_limit
query = query.order_by(Session.started_at.desc().nullslast())
query = query.offset(effective_skip).limit(effective_limit)
result = await db.execute(query)
sessions = result.scalars().all()
return sessions
@router.get("/{session_id}", response_model=SessionResponse)
async def get_session(
session_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Get a specific session."""
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
return session
@router.post("", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
async def start_session(
session_data: SessionCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Start a new troubleshooting session."""
# Get the tree
result = await db.execute(select(Tree).where(Tree.id == session_data.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:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot start session with inactive tree"
)
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"
)
# Deferred variables: sessions can start with empty/partial variables
# Variables are filled inline during execution via PATCH /sessions/{id}/variables
session_variables = session_data.session_variables or {}
# 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,
"tree_type": tree.tree_type,
}
new_session = Session(
tree_id=tree.id,
user_id=current_user.id,
tree_snapshot=tree_snapshot,
path_taken=[],
decisions=[],
ticket_number=session_data.ticket_number,
client_name=session_data.client_name,
session_variables=session_variables,
)
# Atomically increment tree usage count (SQL-level to avoid lost updates)
await db.execute(
sa_update(Tree).where(Tree.id == tree.id).values(usage_count=Tree.usage_count + 1)
)
db.add(new_session)
await db.commit()
await db.refresh(new_session)
return new_session
@router.put("/{session_id}", response_model=SessionResponse)
async def update_session(
session_id: UUID,
session_data: SessionUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Update session (add decisions, notes, etc.)."""
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.completed_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot update a completed session"
)
# Start a prepared session on first update (set started_at)
if session.started_at is None:
session.started_at = datetime.now(timezone.utc)
# Transfer ownership to the executing user if they're the assignee
if session.assigned_to_id == current_user.id and session.user_id != current_user.id:
session.user_id = current_user.id
# Use mode='json' to ensure datetime fields are serialized as ISO strings for JSONB storage
update_data = session_data.model_dump(exclude_unset=True, mode='json')
for field, value in update_data.items():
setattr(session, field, value)
await db.commit()
await db.refresh(session)
return session
@router.post("/{session_id}/complete", response_model=SessionResponse)
async def complete_session(
session_id: UUID,
completion_data: SessionComplete,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Mark session as complete."""
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.completed_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session already completed"
)
session.completed_at = datetime.now(timezone.utc)
session.outcome = completion_data.outcome
session.outcome_notes = completion_data.outcome_notes
session.next_steps = completion_data.next_steps
await db.commit()
await db.refresh(session)
return session
@router.patch("/{session_id}/scratchpad", response_model=SessionResponse)
async def update_scratchpad(
session_id: UUID,
data: ScratchpadUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Update session scratchpad. Accepts updates on both active and completed sessions."""
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
session.scratchpad = data.scratchpad
await db.commit()
await db.refresh(session)
return session
@router.patch("/{session_id}/variables", response_model=SessionResponse)
async def update_session_variables(
session_id: UUID,
data: SessionVariablesUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Update session variables (partial dict merge). Used for deferred variable input."""
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.completed_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot update variables on a completed session"
)
# Merge new variables into existing ones
existing = session.session_variables or {}
merged = {**existing, **data.variables}
session.session_variables = merged
await db.commit()
await db.refresh(session)
return session
@router.post("/{session_id}/export")
async def export_session(
session_id: UUID,
export_options: SessionExport,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Export session to formatted notes."""
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
# PDF export — separate path with binary response
if export_options.format == "pdf":
from app.services.export_service import generate_pdf_export
from fastapi.responses import Response
pdf_bytes = await generate_pdf_export(session, export_options, db)
if session.completed_at:
session.exported = True
await db.commit()
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="session-export-{session_id}.pdf"'},
)
# Query supporting data for non-PDF formats
from app.models.supporting_data import SessionSupportingData
sd_result = await db.execute(
select(SessionSupportingData)
.where(SessionSupportingData.session_id == session_id)
.order_by(SessionSupportingData.sort_order)
)
supporting_data_items = [
{
"label": sd.label,
"data_type": sd.data_type,
"content": sd.content,
"content_type": sd.content_type,
}
for sd in sd_result.scalars().all()
]
# Query file upload evidence for non-PDF formats
from app.models.file_upload import FileUpload
from app.services import storage_service as _storage_service
from app.core.config import settings as _export_settings
upload_items: list[dict] = []
if _export_settings.STORAGE_ENDPOINT:
try:
uploads_result = await db.execute(
select(FileUpload)
.where(FileUpload.session_id == session_id)
.order_by(FileUpload.created_at)
)
for u in uploads_result.scalars().all():
try:
url = _storage_service.get_presigned_url(u.storage_key)
upload_items.append({
"filename": u.filename,
"url": url,
"is_image": u.content_type.startswith("image/"),
})
except Exception:
pass # Skip uploads that fail URL generation
except Exception:
pass # Storage errors should not fail the export
# Generate export based on format
if export_options.format == "markdown":
content = generate_markdown_export(session, export_options, supporting_data=supporting_data_items, uploads=upload_items)
media_type = "text/markdown"
elif export_options.format == "html":
content = generate_html_export(session, export_options, supporting_data=supporting_data_items, uploads=upload_items)
media_type = "text/html"
elif export_options.format == "psa":
content = generate_psa_export(session, export_options, supporting_data=supporting_data_items, uploads=upload_items)
media_type = "text/plain"
else: # text
content = generate_text_export(session, export_options, supporting_data=supporting_data_items, uploads=upload_items)
media_type = "text/plain"
# Resolve variables in export output
session_vars = getattr(session, 'session_variables', None) or {}
if session_vars:
from app.services.variable_service import resolve_variables
content = resolve_variables(content, session_vars)
# Phase C: Apply redaction AFTER generation and variable resolution
redaction_summary = None
if export_options.redaction_mode == "mask":
from app.services.redaction_service import apply_redaction_to_text, format_redaction_footer
try:
content, redaction_summary = apply_redaction_to_text(content)
footer = format_redaction_footer(redaction_summary)
if footer:
content += footer
except Exception:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Redaction processing failed"
)
# Only mark as exported if session is completed
if session.completed_at:
session.exported = True
await db.commit()
# Build response with redaction headers
import json
headers = {"X-Redaction-Mode": export_options.redaction_mode}
if redaction_summary is not None:
headers["X-Redaction-Summary"] = json.dumps(redaction_summary.to_dict())
return PlainTextResponse(content=content, media_type=media_type, headers=headers)
# --- Save Session as Tree ---
@router.post("/{session_id}/save-as-tree", response_model=SaveAsTreeResponse, status_code=status.HTTP_201_CREATED)
async def save_session_as_tree(
session_id: UUID,
request_data: SaveAsTreeRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Save a session as a new tree.
Converts the session's path_taken and custom_steps into a linear tree structure.
The new tree is linked to the original tree via parent_tree_id (fork relationship).
Args:
session_id: ID of the session to save
request_data: Tree name, description, and status
db: Database session
current_user: Current authenticated user
Returns:
SaveAsTreeResponse with new tree ID and name
"""
from app.core.session_to_tree import convert_session_to_tree, generate_tree_name_from_session
from app.core.tree_validation import can_publish_tree
from app.core.subscriptions import check_tree_limit
# Load the session
result = await db.execute(
select(Session).where(
Session.id == session_id,
Session.user_id == current_user.id
)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
# Load the original tree to get metadata
tree_result = await db.execute(
select(Tree).where(Tree.id == session.tree_id)
)
original_tree = tree_result.scalar_one_or_none()
if not original_tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Original tree not found"
)
# Convert session to tree structure
tree_structure = convert_session_to_tree(
session.path_taken,
session.tree_snapshot,
session.custom_steps,
session.decisions
)
# Generate tree name
if request_data.tree_name:
tree_name = request_data.tree_name
else:
tree_name = generate_tree_name_from_session(
original_tree.name,
session.ticket_number,
session.client_name
)
# Validate if status is published
if request_data.status == 'published':
can_publish, validation_errors = can_publish_tree(
tree_structure,
tree_name,
request_data.description,
tree_type=original_tree.tree_type,
intake_form=original_tree.intake_form,
)
if not can_publish:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"message": "Cannot save as published tree with validation errors",
"errors": validation_errors
}
)
# 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."
)
# Create the new tree as a fork of the original
new_tree = Tree(
name=tree_name,
description=request_data.description or f"Saved from troubleshooting session on {session.started_at.strftime('%Y-%m-%d')}",
tree_structure=tree_structure,
author_id=current_user.id,
account_id=current_user.account_id,
status=request_data.status,
is_public=False,
is_default=False,
# Fork tracking - link to original tree
parent_tree_id=original_tree.id,
root_tree_id=original_tree.root_tree_id if original_tree.root_tree_id else original_tree.id,
fork_depth=original_tree.fork_depth + 1,
fork_reason=f"Saved from session: {session.ticket_number or 'No ticket'}" if session.ticket_number else "Saved from troubleshooting session",
parent_updated_at=original_tree.updated_at
)
db.add(new_tree)
await db.commit()
await db.refresh(new_tree)
return SaveAsTreeResponse(
tree_id=new_tree.id,
tree_name=new_tree.name,
message=f"Session saved as {'published' if request_data.status == 'published' else 'draft'} tree"
)
# ── Prepare Session (Flexible Intake) ─────────────────────────────────────
@router.post("/prepare", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
async def prepare_session(
data: PrepareSessionRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Create a prepared session with pre-filled variables and optional assignee.
Prepared sessions have started_at = NULL. They appear in the assigned
engineer's queue and can be started later with variables already filled.
"""
# Get the tree
result = await db.execute(select(Tree).where(Tree.id == data.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:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot prepare session with inactive tree"
)
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 assignee exists and is on the same team (if specified)
if data.assigned_to_id:
assignee_result = await db.execute(select(User).where(User.id == data.assigned_to_id))
assignee = assignee_result.scalar_one_or_none()
if not assignee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Assigned user not found"
)
if current_user.team_id and assignee.team_id != current_user.team_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Can only assign to users on your team"
)
# Create tree snapshot
tree_snapshot = {
**tree.tree_structure,
"name": tree.name,
"description": tree.description,
"category": tree.category,
"version": tree.version,
"tree_type": tree.tree_type,
}
session_variables = data.session_variables or {}
new_session = Session(
tree_id=tree.id,
user_id=data.assigned_to_id or current_user.id,
tree_snapshot=tree_snapshot,
path_taken=[],
decisions=[],
session_variables=session_variables,
ticket_number=data.ticket_number,
client_name=data.client_name,
started_at=None, # NULL = prepared state
prepared_by_id=current_user.id,
assigned_to_id=data.assigned_to_id,
)
db.add(new_session)
await db.commit()
await db.refresh(new_session)
return new_session
# ── Batch Launch (Maintenance Flows) ──────────────────────────────────────
class _BatchTarget(BaseModel):
label: str = PydanticField(..., min_length=1, max_length=255)
class _BatchLaunchRequest(BaseModel):
tree_id: UUID
targets: list[_BatchTarget] = PydanticField(..., min_length=1, max_length=100)
class _BatchLaunchResponse(BaseModel):
batch_id: str
count: int
sessions: list[dict]
@router.post("/batch", status_code=201, response_model=_BatchLaunchResponse)
async def batch_launch_sessions(
data: _BatchLaunchRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Create one session per target for a maintenance flow batch run."""
tree_result = await db.execute(select(Tree).where(Tree.id == data.tree_id))
tree = tree_result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=404, detail="Tree not found")
if not can_access_tree(current_user, tree):
raise HTTPException(status_code=403, detail="Access denied")
if not tree.is_active:
raise HTTPException(status_code=400, detail="Cannot batch-launch an inactive flow")
if tree.status == 'draft':
raise HTTPException(status_code=400, detail="Cannot batch-launch a draft flow")
if not current_user.is_super_admin and tree.team_id != current_user.team_id:
raise HTTPException(status_code=403, detail="Access denied")
if tree.tree_type != "maintenance":
raise HTTPException(status_code=400, detail="Batch launch is only for maintenance flows")
batch_id = uuid.uuid4()
created_sessions = []
# Hoist snapshot computation out of the loop — same tree for all targets
tree_snapshot = {
**tree.tree_structure,
"name": tree.name,
"description": tree.description,
"tree_type": tree.tree_type,
}
for target in data.targets:
session = Session(
tree_id=tree.id,
user_id=current_user.id,
tree_snapshot=tree_snapshot,
path_taken=[],
decisions=[],
custom_steps=[],
session_variables={},
batch_id=batch_id,
target_label=target.label,
)
db.add(session)
created_sessions.append(session)
await db.flush()
session_ids = [s.id for s in created_sessions]
result = await db.execute(select(Session).where(Session.id.in_(session_ids)))
created_sessions = result.scalars().all()
await db.commit()
return _BatchLaunchResponse(
batch_id=str(batch_id),
count=len(created_sessions),
sessions=[
{
"id": str(s.id),
"batch_id": str(s.batch_id),
"target_label": s.target_label,
"tree_id": str(s.tree_id),
}
for s in created_sessions
],
)
# ── PSA Ticket Link ─────────────────────────────────────────────────
@router.patch("/{session_id}/ticket-link", response_model=TicketLinkResponse)
async def link_ticket(
session_id: UUID,
data: TicketLinkRequest,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Link or unlink a PSA ticket to/from a session."""
from app.models.psa_connection import PsaConnection
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSANotFoundError, PSAError
# Look up session
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found",
)
# Verify ownership or admin
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
if not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found",
)
# Unlink
if data.psa_ticket_id is None:
session.psa_ticket_id = None
session.psa_connection_id = None
await db.commit()
return TicketLinkResponse(
session_id=str(session.id),
psa_ticket_id=None,
ticket=None,
)
# Link — validate ticket exists in CW
if not current_user.account_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No account associated with your user",
)
try:
provider = await get_provider_for_account(current_user.account_id, db)
except PSAError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
# Fetch the connection to store its ID
conn_result = await db.execute(
select(PsaConnection).where(
PsaConnection.account_id == current_user.account_id,
PsaConnection.is_active.is_(True),
)
)
psa_connection = conn_result.scalar_one_or_none()
try:
ticket = await provider.get_ticket(data.psa_ticket_id)
except PSANotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found in ConnectWise",
)
except PSAError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"PSA error: {exc}",
)
session.psa_ticket_id = ticket.id
session.psa_connection_id = psa_connection.id if psa_connection else None
await db.commit()
return TicketLinkResponse(
session_id=str(session.id),
psa_ticket_id=ticket.id,
ticket=PSATicketResponse(
id=ticket.id,
summary=ticket.summary,
company_name=ticket.company_name,
board_name=ticket.board_name,
status_name=ticket.status_name,
priority_name=ticket.priority_name,
),
)
# ── PSA Post to Ticket ────────────────────────────────────────────
@router.get("/{session_id}/psa-post/preview")
async def psa_post_preview(
session_id: UUID,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Preview the content that will be posted to the linked PSA ticket.
Generates session documentation in PSA format, fetches current ticket
details and available statuses, and counts previous posts.
"""
from app.models.psa_post_log import PsaPostLog
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
from app.schemas.psa_connection import (
PsaPreviewResponse,
PSATicketSearchResult,
PSATicketStatusItem,
)
from sqlalchemy import func as sa_func
# Load session
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
if not current_user.is_super_admin:
raise HTTPException(status_code=403, detail="You don't have access to this session")
if not session.psa_ticket_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Session has no linked PSA ticket. Link a ticket first.",
)
if not current_user.account_id:
raise HTTPException(status_code=400, detail="No account associated with your user")
# Generate PSA export content
export_options = SessionExport(
format="psa",
include_timestamps=True,
include_tree_info=True,
include_outcome_notes=True,
include_next_steps=True,
include_summary=True,
)
content = generate_psa_export(session, export_options)
# Resolve session variables in content
session_vars = getattr(session, "session_variables", None) or {}
if session_vars:
from app.services.variable_service import resolve_variables
content = resolve_variables(content, session_vars)
# Fetch ticket details and statuses from CW
try:
provider = await get_provider_for_account(current_user.account_id, db)
ticket = await provider.get_ticket(session.psa_ticket_id)
available_statuses: list[PSATicketStatusItem] = []
if ticket.board_id:
statuses = await provider.get_ticket_statuses(ticket.board_id)
available_statuses = [
PSATicketStatusItem(id=s.id, name=s.name, is_closed=s.is_closed)
for s in statuses
]
except PSAError as e:
raise HTTPException(status_code=502, detail=f"PSA error: {e}")
# Count previous posts
count_result = await db.execute(
select(sa_func.count(PsaPostLog.id)).where(
PsaPostLog.session_id == session_id
)
)
previous_posts = count_result.scalar_one()
return PsaPreviewResponse(
content=content,
ticket=PSATicketSearchResult(
id=ticket.id,
summary=ticket.summary,
company_name=ticket.company_name,
board_name=ticket.board_name,
status_name=ticket.status_name,
priority_name=ticket.priority_name,
closed=ticket.closed,
),
available_statuses=available_statuses,
character_count=len(content),
previous_posts=previous_posts,
)
@router.post("/{session_id}/psa-post")
async def psa_post_to_ticket(
session_id: UUID,
data: PsaPostRequest,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Post session documentation as a note to the linked PSA ticket.
Optionally updates the ticket status if update_status_id is provided.
All actions are logged in psa_post_log for audit trail.
"""
from app.models.psa_connection import PsaConnection
from app.models.psa_post_log import PsaPostLog
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
from app.schemas.psa_connection import PsaPostResponse
# Load session
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
if not current_user.is_super_admin:
raise HTTPException(status_code=403, detail="You don't have access to this session")
if not session.psa_ticket_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Session has no linked PSA ticket. Link a ticket first.",
)
if not current_user.account_id:
raise HTTPException(status_code=400, detail="No account associated with your user")
# Get PSA connection ID for audit
conn_result = await db.execute(
select(PsaConnection).where(
PsaConnection.account_id == current_user.account_id,
PsaConnection.is_active.is_(True),
)
)
psa_connection = conn_result.scalar_one_or_none()
# Look up member mapping for attribution
from app.models.psa_member_mapping import PsaMemberMapping
member_id = None
if psa_connection:
mapping_result = await db.execute(
select(PsaMemberMapping).where(
PsaMemberMapping.psa_connection_id == psa_connection.id,
PsaMemberMapping.user_id == current_user.id,
)
)
mapping = mapping_result.scalar_one_or_none()
if mapping:
member_id = mapping.external_member_id
# Post note
try:
provider = await get_provider_for_account(current_user.account_id, db)
note_result = await provider.post_note(
ticket_id=session.psa_ticket_id,
text=data.content,
note_type=data.note_type,
member_id=member_id,
)
note_status = "success"
external_note_id = note_result.id
error_message = None
except PSAError as e:
note_status = "failed"
external_note_id = None
error_message = str(e)
# Optionally update ticket status
status_changed_from = None
status_changed_to = None
if data.update_status_id and note_status == "success":
try:
# Get current status before update
current_ticket = await provider.get_ticket(session.psa_ticket_id)
status_changed_from = current_ticket.status_name
if current_ticket.status_id != data.update_status_id:
updated_ticket = await provider.update_ticket_status(
session.psa_ticket_id, data.update_status_id
)
status_changed_to = updated_ticket.status_name
except PSAError as e:
# Log the status update failure but don't fail the whole request
# since the note was already posted successfully
if error_message:
error_message += f"; Status update failed: {e}"
else:
error_message = f"Note posted successfully but status update failed: {e}"
# Log to audit trail
log_entry = PsaPostLog(
session_id=session.id,
psa_connection_id=psa_connection.id if psa_connection else None,
ticket_id=session.psa_ticket_id,
note_type=data.note_type,
content_posted=data.content,
external_note_id=external_note_id,
status=note_status,
error_message=error_message,
status_changed_from=status_changed_from,
status_changed_to=status_changed_to,
posted_by=current_user.id,
)
db.add(log_entry)
await db.commit()
await db.refresh(log_entry)
if note_status == "failed":
raise HTTPException(
status_code=502,
detail=error_message or "Failed to post note to PSA",
)
return PsaPostResponse(
id=str(log_entry.id),
session_id=str(session.id),
ticket_id=session.psa_ticket_id,
note_type=data.note_type,
status=note_status,
external_note_id=external_note_id,
error_message=error_message,
status_changed_from=status_changed_from,
status_changed_to=status_changed_to,
posted_at=log_entry.posted_at.isoformat(),
)
@router.get("/{session_id}/psa-posts")
async def list_psa_posts(
session_id: UUID,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List all PSA post history for a session, ordered by most recent first."""
from app.models.psa_post_log import PsaPostLog
from app.schemas.psa_connection import PsaPostLogResponse
# Verify session access
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
if not current_user.is_super_admin:
raise HTTPException(status_code=403, detail="You don't have access to this session")
# Query post log
log_result = await db.execute(
select(PsaPostLog)
.where(PsaPostLog.session_id == session_id)
.order_by(PsaPostLog.posted_at.desc())
)
logs = log_result.scalars().all()
return [
PsaPostLogResponse(
id=str(log.id),
ticket_id=log.ticket_id,
note_type=log.note_type,
status=log.status,
error_message=log.error_message,
status_changed_from=log.status_changed_from,
status_changed_to=log.status_changed_to,
posted_at=log.posted_at.isoformat(),
content_preview=log.content_posted[:200],
)
for log in logs
]