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>
201 lines
6.6 KiB
Python
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,
|
|
)
|