Files
resolutionflow/backend/app/api/endpoints/sidebar.py
Michael Chihlas 029680ab2d
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
CI / frontend (pull_request) Successful in 5m8s
CI / backend (pull_request) Successful in 10m13s
CI / e2e (pull_request) Successful in 10m47s
feat(escalations): unify /escalate through HandoffManager
Replaces the legacy flowpilot_engine.escalate_session orchestration with
a single canonical path through HandoffManager. Every escalation now
creates a SessionHandoff row, fans out via the SSE bus, persists
AppNotification rows for the bell icon, dispatches to external channels
(Slack/Teams) via notify(), and emails per-user — regardless of whether
the call entered through /escalate (legacy URL) or /handoff (new URL).
The senior-pickup magic-moment screen now works end-to-end from the
EscalateModal bell-icon path the user just tested.

Backend
- HandoffCreateRequest gains optional target_user_id (the equivalent of
  the legacy escalated_to_id field). Self-targeting rejected.
- HandoffManager.create_handoff handles intent='escalate' end-to-end:
  sets escalation_reason + escalated_to_id, builds the legacy enhanced
  AI escalation_package (Sonnet, lazy-imported from flowpilot_engine,
  graceful fallback on failure), and merges handoff metadata into it.
  Eager-loads session.steps and session.user via selectinload — required
  by both the enhanced-package builder and notify() to avoid
  MissingGreenlet on async lazy access.
- HandoffManager.finalize_escalation generates SessionDocumentation,
  pushes documentation to PSA, and runs notify() — pre-commit so the
  AppNotification rows persist atomically with the handoff.
- HandoffManager.dispatch_escalation_notifications keeps only the
  fire-and-forget IO (bus publish, per-user emails) — runs post-commit.
  Pulls engineer name via a separate User query rather than relying on
  session.user lazy access.
- /handoff endpoint passes target_user_id through and calls
  finalize_escalation pre-commit.
- /escalate endpoint is now a thin shim: owner-only session lookup,
  HandoffManager.create_handoff(intent='escalate'), finalize_escalation,
  commit, dispatch_escalation_notifications, return SessionCloseResponse
  built from documentation + psa_result. flowpilot_engine.escalate_session
  is no longer called by any endpoint.
- pickup_session accepts both 'requesting_escalation' (legacy in-flight
  sessions) and 'escalated' (new canonical) so the migration is seamless
  for sessions already in the queue.
- Escalation queue list and sidebar count now match either status.

Frontend
- useFlowPilotSession optimistic update flips status to 'escalated'
  instead of 'requesting_escalation' so the page state matches the
  unified backend response.

Verified end-to-end live: a fresh /escalate call from the junior produces
status='escalated', a SessionHandoff row, a SessionDocumentation, PSA
push attempted (no_psa for this test session), AND a bell-icon
AppNotification for the team admin with link
/pilot/{session_id}?pickup=true. Backend test suite: 1103 passed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 22:27:26 -04:00

201 lines
6.6 KiB
Python

"""Sidebar stats and activity feed endpoint."""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.api.deps import get_current_active_user
from app.models.ai_session import AISession
from app.models.session import Session
from app.models.tree import Tree
from app.models.user import User
from app.schemas.sidebar import (
SidebarActiveSession,
SidebarRecentSession,
SidebarStatsResponse,
SidebarTreeCounts,
)
router = APIRouter(prefix="/sessions", tags=["sidebar"])
@router.get("/sidebar-stats", response_model=SidebarStatsResponse)
async def get_sidebar_stats(
tz_offset: int = Query(..., description="Client UTC offset in minutes (e.g. -300 for EST)"),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> SidebarStatsResponse:
"""Get sidebar stats and activity feed for the current user.
Returns daily stats (resolved count, active count, time in session)
and lists of active + recently completed sessions.
"""
# Compute "today" start in user's timezone, then convert to UTC
now_utc = datetime.now(timezone.utc)
user_tz = timezone(timedelta(minutes=-tz_offset))
now_local = now_utc.astimezone(user_tz)
today_start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
today_start_utc = today_start_local.astimezone(timezone.utc)
user_filter = Session.user_id == current_user.id
# --- Resolved today ---
resolved_result = await db.execute(
select(func.count()).where(
and_(
user_filter,
Session.completed_at >= today_start_utc,
Session.outcome == "resolved",
)
)
)
resolved_today = resolved_result.scalar() or 0
# --- Active count (all time, not just today) ---
active_result = await db.execute(
select(func.count()).where(
and_(
user_filter,
Session.started_at.isnot(None),
Session.completed_at.is_(None),
)
)
)
active_count = active_result.scalar() or 0
# --- Completed session minutes today (active session time computed client-side) ---
duration_expr = func.extract(
"epoch",
Session.completed_at - Session.started_at,
) / 60.0
duration_result = await db.execute(
select(func.coalesce(func.sum(duration_expr), 0)).where(
and_(
user_filter,
Session.started_at.isnot(None),
Session.completed_at.isnot(None),
Session.started_at >= today_start_utc,
)
)
)
total_minutes = int(duration_result.scalar() or 0)
# --- Active sessions (max 5, most recent first) ---
active_sessions_result = await db.execute(
select(
Session.id,
Session.tree_id,
Session.started_at,
Session.ticket_number,
Session.psa_ticket_id,
Session.tree_snapshot["name"].as_string().label("tree_name"),
Session.tree_snapshot["tree_type"].as_string().label("tree_type"),
)
.where(
and_(
user_filter,
Session.started_at.isnot(None),
Session.completed_at.is_(None),
)
)
.order_by(Session.started_at.desc())
.limit(5)
)
active_sessions = [
SidebarActiveSession(
session_id=row.id,
tree_name=row.tree_name or "Unknown Flow",
tree_id=row.tree_id,
tree_type=row.tree_type or "troubleshooting",
started_at=row.started_at,
ticket_number=row.ticket_number,
psa_ticket_id=row.psa_ticket_id,
)
for row in active_sessions_result.all()
]
# --- Recent completions (max 3, completed today, most recent first) ---
recent_result = await db.execute(
select(
Session.id,
Session.tree_id,
Session.completed_at,
Session.tree_snapshot["name"].as_string().label("tree_name"),
Session.tree_snapshot["tree_type"].as_string().label("tree_type"),
)
.where(
and_(
user_filter,
Session.completed_at.isnot(None),
Session.completed_at >= today_start_utc,
)
)
.order_by(Session.completed_at.desc())
.limit(3)
)
recent_completions = [
SidebarRecentSession(
session_id=row.id,
tree_name=row.tree_name or "Unknown Flow",
tree_id=row.tree_id,
tree_type=row.tree_type or "troubleshooting",
completed_at=row.completed_at,
)
for row in recent_result.all()
]
# --- Escalation count (team/account-wide pending escalations) ---
escalation_count = 0
if current_user.team_id:
esc_scope = AISession.team_id == current_user.team_id
elif current_user.account_id:
esc_scope = AISession.account_id == current_user.account_id
else:
esc_scope = None
if esc_scope is not None:
esc_result = await db.execute(
select(func.count()).where(
and_(
esc_scope,
AISession.status.in_(("requesting_escalation", "escalated")),
)
)
)
escalation_count = esc_result.scalar() or 0
# --- Tree counts (for All Flows sub-items) ---
tree_counts_result = await db.execute(
select(
func.count().label("total"),
func.count().filter(Tree.tree_type == "troubleshooting").label("troubleshooting"),
func.count().filter(Tree.tree_type == "procedural").label("procedural"),
func.count().filter(Tree.tree_type == "maintenance").label("maintenance"),
).where(
and_(
Tree.account_id == current_user.account_id,
Tree.is_active.is_(True),
Tree.deleted_at.is_(None),
)
)
)
tc = tree_counts_result.one()
return SidebarStatsResponse(
resolved_today=resolved_today,
active_count=active_count,
total_session_minutes_today=total_minutes,
escalation_count=escalation_count,
tree_counts=SidebarTreeCounts(
total=tc.total,
troubleshooting=tc.troubleshooting,
procedural=tc.procedural,
maintenance=tc.maintenance,
),
active_sessions=active_sessions,
recent_completions=recent_completions,
)