Files
resolutionflow/backend/app/api/endpoints/sidebar.py
chihlasm a7b916116d fix(escalations): fall back to account_id when team_id is null
Users without team_id (solo accounts, pro plans) couldn't see
escalations because the query filtered by team_id which was NULL.
Now falls back to account_id scoping for both the escalation queue
endpoint and the sidebar badge count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 05:04:47 +00: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 == "requesting_escalation",
)
)
)
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,
)