Files
resolutionflow/backend/app/api/endpoints/l1.py
Michael Chihlas e8ca15d245 docs(l1): document session-ownership policy in _get_session_or_404
Sessions are account-scoped (per spec §7.9), not user-scoped, to support
team coverage. Comment-only fix surfaced by final review.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:48:49 -04:00

278 lines
9.0 KiB
Python

"""L1 Workspace endpoints (Phase 1).
PSA-merge queue support + AI build path are deferred to Phase 2.
"""
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status as http_status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_l1_or_coverage
from app.models.l1_walk_session import L1WalkSession
from app.models.user import User
from app.schemas.l1 import (
EscalateRequest,
EscalateWithoutWalkRequest,
IntakeRequest,
IntakeResponse,
NotesRequest,
QueueRow,
ResolveRequest,
StepRequest,
WalkSessionResponse,
)
from app.services import internal_ticket_service, l1_session_service
router = APIRouter(prefix="/l1", tags=["l1"])
def _to_response(session: L1WalkSession) -> WalkSessionResponse:
return WalkSessionResponse(
id=session.id,
session_kind=session.session_kind,
flow_id=session.flow_id,
flow_proposal_id=session.flow_proposal_id,
current_node_id=session.current_node_id,
walked_path=session.walked_path or [],
walk_notes=session.walk_notes or [],
status=session.status,
started_at=session.started_at,
last_step_at=session.last_step_at,
resolved_at=session.resolved_at,
)
async def _get_session_or_404(
db: AsyncSession, session_id: UUID, user: User
) -> L1WalkSession:
"""Fetch a session by id, scoped to the caller's account.
Phase 1 policy (per spec §7.9): sessions are account-scoped, not
user-scoped. Any L1 or coverage engineer in the same account can
step/note/resolve/escalate any session — supports team coverage
(e.g., L1 hands off mid-shift; coverage engineer takes over a call).
For a stricter "creator-only" policy, add
``created_by_user_id == user.id`` here.
"""
session = await db.get(L1WalkSession, session_id)
if session is None or session.account_id != user.account_id:
raise HTTPException(
status_code=http_status.HTTP_404_NOT_FOUND,
detail="Session not found",
)
return session
@router.post("/intake", response_model=IntakeResponse)
async def intake(
payload: IntakeRequest,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
"""L1 intake: creates an internal ticket and starts a walk session.
Phase 1: internal-ticket only (PSA support follows in Phase 2 escalation polish).
If `flow_id` is provided, starts a flow session; otherwise an adhoc session.
"""
ticket = await internal_ticket_service.create_ticket(
db,
account_id=user.account_id,
created_by_user_id=user.id,
problem_statement=payload.problem_statement,
customer_name=payload.customer_name,
customer_contact=payload.customer_contact,
)
if payload.flow_id is not None:
session = await l1_session_service.start_flow_session(
db,
account_id=user.account_id,
user=user,
flow_id=payload.flow_id,
ticket_id=str(ticket.id),
ticket_kind="internal",
)
else:
session = await l1_session_service.start_adhoc_session(
db,
account_id=user.account_id,
user=user,
ticket_id=str(ticket.id),
ticket_kind="internal",
)
await db.commit()
return IntakeResponse(
session_id=session.id,
session_kind=session.session_kind,
ticket_id=str(ticket.id),
ticket_kind="internal",
)
@router.get("/queue", response_model=list[QueueRow])
async def queue(
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
status_filter: Optional[str] = None,
limit: int = 50,
):
"""Phase 1 queue: internal tickets only. PSA-fed rows in Phase 2."""
tickets = await internal_ticket_service.list_tickets_for_account(
db,
account_id=user.account_id,
status=status_filter,
limit=limit,
)
return [
QueueRow(
ticket_id=str(t.id),
ticket_kind="internal",
problem_statement=t.problem_statement,
customer_name=t.customer_name,
status=t.status,
created_at=t.created_at,
)
for t in tickets
]
@router.get("/sessions/active", response_model=list[WalkSessionResponse])
async def list_active_sessions(
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
"""The caller's currently-active sessions (for the dashboard 'Resume in progress' widget)."""
stmt = (
select(L1WalkSession)
.where(L1WalkSession.created_by_user_id == user.id)
.where(L1WalkSession.status == "active")
.order_by(L1WalkSession.last_step_at.desc())
.limit(20)
)
result = await db.execute(stmt)
return [_to_response(s) for s in result.scalars()]
@router.get("/sessions/{session_id}", response_model=WalkSessionResponse)
async def get_session(
session_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
session = await _get_session_or_404(db, session_id, user)
return _to_response(session)
@router.post("/sessions/{session_id}/step", response_model=WalkSessionResponse)
async def post_step(
session_id: UUID,
payload: StepRequest,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
await _get_session_or_404(db, session_id, user)
try:
updated = await l1_session_service.record_step(
db,
session_id=session_id,
node_id=payload.node_id,
question=payload.question,
answer=payload.answer,
note=payload.note,
)
except ValueError as exc:
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
await db.commit()
return _to_response(updated)
@router.post("/sessions/{session_id}/notes", response_model=WalkSessionResponse)
async def post_notes(
session_id: UUID,
payload: NotesRequest,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
await _get_session_or_404(db, session_id, user)
try:
updated = await l1_session_service.update_notes(
db,
session_id=session_id,
notes=payload.notes,
)
except ValueError as exc:
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
await db.commit()
return _to_response(updated)
@router.post("/sessions/{session_id}/resolve", response_model=WalkSessionResponse)
async def post_resolve(
session_id: UUID,
payload: ResolveRequest,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
await _get_session_or_404(db, session_id, user)
try:
updated = await l1_session_service.resolve(
db,
session_id=session_id,
helpful=payload.helpful,
resolution_notes=payload.resolution_notes,
)
except ValueError as exc:
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
await db.commit()
return _to_response(updated)
@router.post("/sessions/{session_id}/escalate", response_model=WalkSessionResponse)
async def post_escalate(
session_id: UUID,
payload: EscalateRequest,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
await _get_session_or_404(db, session_id, user)
try:
updated = await l1_session_service.escalate(
db,
session_id=session_id,
reason=payload.reason or "",
reason_category=payload.reason_category,
)
except ValueError as exc:
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
await db.commit()
return _to_response(updated)
@router.post("/escalate-without-walk", response_model=WalkSessionResponse)
async def post_escalate_without_walk(
payload: EscalateWithoutWalkRequest,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
ticket = await internal_ticket_service.create_ticket(
db,
account_id=user.account_id,
created_by_user_id=user.id,
problem_statement=payload.problem_statement,
customer_name=payload.customer_name,
customer_contact=payload.customer_contact,
)
session = await l1_session_service.escalate_without_walk(
db,
account_id=user.account_id,
user=user,
ticket_id=str(ticket.id),
ticket_kind="internal",
reason_category=payload.reason_category,
reason=payload.reason,
)
await db.commit()
return _to_response(session)