"""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)