diff --git a/backend/app/api/endpoints/flowpilot_analytics.py b/backend/app/api/endpoints/flowpilot_analytics.py index c92a55aa..75c91378 100644 --- a/backend/app/api/endpoints/flowpilot_analytics.py +++ b/backend/app/api/endpoints/flowpilot_analytics.py @@ -18,6 +18,7 @@ from app.models.user import User from app.models.tree import Tree from app.models.ai_session import AISession from app.models.flow_proposal import FlowProposal +from app.models.psa_activity_log import PsaActivityLog from app.models.psa_post_log import PsaPostLog from app.models.category import TreeCategory from app.schemas.flowpilot_analytics import ( @@ -32,6 +33,9 @@ from app.schemas.flowpilot_analytics import ( CoverageResponse, FlowQualityRow, FlowQualityResponse, + EnhancedPsaMetrics, + PsaFunnel, + PsaDailyTrend, ) from app.services.knowledge_gap_service import get_knowledge_gaps, KnowledgeGapReport @@ -613,3 +617,121 @@ async def get_flow_quality( top_performers=top_performers, needs_attention=needs_attention, ) + + +@router.get("/psa-metrics", response_model=EnhancedPsaMetrics) +@limiter.limit("15/minute") +async def get_enhanced_psa_metrics( + request: Request, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_team_admin), + period: str = Query("30d", pattern="^(7d|30d|90d)$"), +): + """Get enhanced PSA integration metrics including time entry stats, push funnel, and daily trend.""" + if not current_user.account_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No account") + + account_id = current_user.account_id + period_start = _get_period_start(period) + + # ── Time entry totals from psa_activity_logs ── + time_entry_result = await db.execute( + select( + func.count(PsaActivityLog.id).label("entry_count"), + func.sum(PsaActivityLog.hours_logged).label("total_hours"), + ) + .where( + PsaActivityLog.account_id == account_id, + PsaActivityLog.activity_type == "time_entry_posted", + PsaActivityLog.created_at >= period_start, + ) + ) + te_row = time_entry_result.one() + total_time_entries = int(te_row.entry_count or 0) + total_hours_raw = te_row.total_hours or 0 + total_hours_logged = round(float(total_hours_raw), 2) + avg_hours = round(total_hours_logged / total_time_entries, 2) if total_time_entries > 0 else 0.0 + + # ── Push funnel ── + # Total sessions in period + total_sessions_result = await db.execute( + select(func.count(AISession.id)).where( + AISession.account_id == account_id, + AISession.created_at >= period_start, + ) + ) + total_sessions = int(total_sessions_result.scalar() or 0) + + # Sessions linked to a ticket + linked_result = await db.execute( + select(func.count(AISession.id)).where( + AISession.account_id == account_id, + AISession.created_at >= period_start, + AISession.psa_ticket_id.isnot(None), + ) + ) + linked_to_ticket = int(linked_result.scalar() or 0) + + # Sessions with a successful doc push (via PsaPostLog) + doc_pushed_result = await db.execute( + select(func.count(PsaPostLog.id.distinct())) + .join(AISession, PsaPostLog.ai_session_id == AISession.id) + .where( + AISession.account_id == account_id, + PsaPostLog.ai_session_id.isnot(None), + PsaPostLog.status == "success", + PsaPostLog.posted_at >= period_start, + ) + ) + doc_pushed = int(doc_pushed_result.scalar() or 0) + + # Sessions with a time entry logged (via psa_activity_logs) + time_entry_sessions_result = await db.execute( + select(func.count(PsaActivityLog.session_id.distinct())).where( + PsaActivityLog.account_id == account_id, + PsaActivityLog.activity_type == "time_entry_posted", + PsaActivityLog.created_at >= period_start, + PsaActivityLog.session_id.isnot(None), + ) + ) + time_entry_logged = int(time_entry_sessions_result.scalar() or 0) + + push_funnel = PsaFunnel( + total_sessions=total_sessions, + linked_to_ticket=linked_to_ticket, + doc_pushed=doc_pushed, + time_entry_logged=time_entry_logged, + ) + + # ── Daily trend (time entries grouped by date) ── + daily_result = await db.execute( + select( + cast(PsaActivityLog.created_at, Date).label("day"), + func.count(PsaActivityLog.id).label("entries"), + func.sum(PsaActivityLog.hours_logged).label("hours"), + ) + .where( + PsaActivityLog.account_id == account_id, + PsaActivityLog.activity_type == "time_entry_posted", + PsaActivityLog.created_at >= period_start, + ) + .group_by(cast(PsaActivityLog.created_at, Date)) + .order_by(cast(PsaActivityLog.created_at, Date)) + ) + daily_trend = [ + PsaDailyTrend( + date=str(r.day), + entries=int(r.entries or 0), + hours=round(float(r.hours or 0), 2), + ) + for r in daily_result.all() + ] + + return EnhancedPsaMetrics( + total_time_entries=total_time_entries, + total_hours_logged=total_hours_logged, + avg_hours_per_session=avg_hours, + push_funnel=push_funnel, + daily_trend=daily_trend, + ) diff --git a/backend/app/schemas/flowpilot_analytics.py b/backend/app/schemas/flowpilot_analytics.py index 196fd24a..b3155283 100644 --- a/backend/app/schemas/flowpilot_analytics.py +++ b/backend/app/schemas/flowpilot_analytics.py @@ -87,6 +87,27 @@ class FlowQualityResponse(BaseModel): needs_attention: list[FlowQualityRow] +class PsaFunnel(BaseModel): + total_sessions: int + linked_to_ticket: int + doc_pushed: int + time_entry_logged: int + + +class PsaDailyTrend(BaseModel): + date: str + entries: int + hours: float + + +class EnhancedPsaMetrics(BaseModel): + total_time_entries: int + total_hours_logged: float + avg_hours_per_session: float + push_funnel: PsaFunnel + daily_trend: list[PsaDailyTrend] + + class FlowPilotDashboard(BaseModel): period: str total_sessions: int diff --git a/backend/app/services/psa_documentation_service.py b/backend/app/services/psa_documentation_service.py index f8cf4a01..88b2475d 100644 --- a/backend/app/services/psa_documentation_service.py +++ b/backend/app/services/psa_documentation_service.py @@ -14,6 +14,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.ai_session import AISession +from app.models.psa_activity_log import PsaActivityLog from app.models.psa_connection import PsaConnection from app.models.psa_member_mapping import PsaMemberMapping from app.models.psa_post_log import PsaPostLog @@ -303,6 +304,7 @@ async def push_documentation( ) # Create time entry if member mapping exists + time_entry_hours: Optional[float] = None if member_mapping and session.resolved_at and session.created_at: try: delta = session.resolved_at - session.created_at @@ -316,10 +318,38 @@ async def push_documentation( hours=rounded_hours, notes=f"FlowPilot session: {session.problem_summary or 'Troubleshooting'}", ) + time_entry_hours = rounded_hours except Exception as e: logger.warning("Failed to create time entry for session %s: %s", session.id, e) # Don't fail the note push just because time entry failed + # Log PSA activity — note posted + try: + note_activity = PsaActivityLog( + account_id=session.account_id, + session_id=session.id, + activity_type="note_posted", + hours_logged=None, + psa_ticket_id=session.psa_ticket_id, + ) + db.add(note_activity) + except Exception as e: + logger.warning("Failed to log PSA note activity for session %s: %s", session.id, e) + + # Log time entry activity if one was created + if time_entry_hours is not None: + try: + time_activity = PsaActivityLog( + account_id=session.account_id, + session_id=session.id, + activity_type="time_entry_posted", + hours_logged=time_entry_hours, + psa_ticket_id=session.psa_ticket_id, + ) + db.add(time_activity) + except Exception as e: + logger.warning("Failed to log PSA time entry activity for session %s: %s", session.id, e) + # Log success log_entry = PsaPostLog( id=uuid.uuid4(),