diff --git a/backend/alembic/versions/039_add_session_ratings_and_analytics_indexes.py b/backend/alembic/versions/039_add_session_ratings_and_analytics_indexes.py new file mode 100644 index 00000000..0baf92ec --- /dev/null +++ b/backend/alembic/versions/039_add_session_ratings_and_analytics_indexes.py @@ -0,0 +1,45 @@ +"""Add session_ratings table and analytics indexes + +Revision ID: 039 +Revises: 038 +Create Date: 2026-02-16 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +revision = '039' +down_revision = '038' + +def upgrade(): + # New session_ratings table for flow CSAT ratings + op.create_table( + 'session_ratings', + sa.Column('id', UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), primary_key=True), + sa.Column('session_id', UUID(as_uuid=True), sa.ForeignKey('sessions.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('tree_id', UUID(as_uuid=True), sa.ForeignKey('trees.id', ondelete='CASCADE'), nullable=False), + sa.Column('account_id', UUID(as_uuid=True), sa.ForeignKey('accounts.id', ondelete='CASCADE'), nullable=False), + sa.Column('rating', sa.Integer, nullable=False), + sa.Column('comment', sa.String(500), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), + sa.CheckConstraint('rating >= 1 AND rating <= 5', name='ck_session_ratings_rating_range'), + sa.UniqueConstraint('session_id', name='uq_session_ratings_session_id'), + ) + + # Analytics indexes + op.create_index('ix_session_ratings_tree_created', 'session_ratings', ['tree_id', 'created_at']) + op.create_index('ix_session_ratings_account_created', 'session_ratings', ['account_id', 'created_at']) + op.create_index('ix_sessions_completed', 'sessions', ['completed_at']) + op.create_index('ix_step_ratings_step_helpful', 'step_ratings', ['step_id', 'was_helpful']) + + # Make step_ratings.rating column nullable (thumbs-only mode) + op.alter_column('step_ratings', 'rating', nullable=True) + +def downgrade(): + op.alter_column('step_ratings', 'rating', nullable=False) + op.drop_index('ix_step_ratings_step_helpful', 'step_ratings') + op.drop_index('ix_sessions_completed', 'sessions') + op.drop_index('ix_session_ratings_account_created', 'session_ratings') + op.drop_index('ix_session_ratings_tree_created', 'session_ratings') + op.drop_table('session_ratings') diff --git a/backend/app/api/endpoints/analytics.py b/backend/app/api/endpoints/analytics.py new file mode 100644 index 00000000..41a3b015 --- /dev/null +++ b/backend/app/api/endpoints/analytics.py @@ -0,0 +1,404 @@ +from datetime import datetime, timezone, timedelta +from uuid import UUID +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select, func, case, cast, Date +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 import User, Session, Tree, SessionRating +from app.schemas.analytics import ( + TeamAnalyticsResponse, PersonalAnalyticsResponse, FlowAnalyticsResponse, + AnalyticsSummary, OutcomeBreakdown, TimeSeriesPoint, + TopFlow, TopEngineer, StepFeedbackSummary, FlowRatingItem, +) + +router = APIRouter(prefix="/analytics", tags=["analytics"]) + + +def _get_period_start(period: str) -> datetime: + days = {"7d": 7, "30d": 30, "90d": 90}.get(period, 30) + return datetime.now(timezone.utc) - timedelta(days=days) + + +async def _build_summary( + db: AsyncSession, + base_filters: list, + join_tree: bool = False, +) -> AnalyticsSummary: + """Build analytics summary. If join_tree=True, joins Session->Tree for account scoping.""" + + def _base_query(): + q = select(func.count()).select_from(Session) + if join_tree: + q = q.join(Tree, Session.tree_id == Tree.id) + return q + + # Total sessions + total_q = await db.execute(_base_query().where(*base_filters)) + total = total_q.scalar() or 0 + + # Completed sessions + completed_q = await db.execute( + _base_query().where(*base_filters, Session.completed_at.isnot(None)) + ) + completed = completed_q.scalar() or 0 + + # Median duration (minutes) using percentile_cont + duration_base = select( + func.percentile_cont(0.5).within_group( + func.extract('epoch', Session.completed_at - Session.started_at) / 60 + ) + ).select_from(Session) + if join_tree: + duration_base = duration_base.join(Tree, Session.tree_id == Tree.id) + duration_q = await db.execute( + duration_base.where(*base_filters, Session.completed_at.isnot(None)) + ) + raw_median = duration_q.scalar() + median_duration = round(float(raw_median), 1) if raw_median is not None else 0.0 + + # Active engineers (distinct users) + active_base = select(func.count(func.distinct(Session.user_id))).select_from(Session) + if join_tree: + active_base = active_base.join(Tree, Session.tree_id == Tree.id) + active_q = await db.execute(active_base.where(*base_filters)) + active_engineers = active_q.scalar() or 0 + + # Outcome breakdown + outcome_base = select(Session.outcome, func.count()).select_from(Session) + if join_tree: + outcome_base = outcome_base.join(Tree, Session.tree_id == Tree.id) + outcome_q = await db.execute( + outcome_base.where( + *base_filters, Session.completed_at.isnot(None), Session.outcome.isnot(None) + ).group_by(Session.outcome) + ) + outcomes = dict(outcome_q.all()) + + return AnalyticsSummary( + total_sessions=total, + completed_sessions=completed, + completion_rate=round(completed / total, 3) if total > 0 else 0.0, + median_duration_minutes=median_duration, + active_engineers=active_engineers, + outcome_breakdown=OutcomeBreakdown( + resolved=outcomes.get("resolved", 0), + escalated=outcomes.get("escalated", 0), + workaround=outcomes.get("workaround", 0), + unresolved=outcomes.get("unresolved", 0), + ), + ) + + +async def _build_time_series( + db: AsyncSession, + base_filters: list, + join_tree: bool = False, +) -> list[TimeSeriesPoint]: + """Build daily time-series using CASE expressions for outcome counting.""" + q = select( + cast(Session.started_at, Date).label("date"), + func.count().label("sessions"), + func.sum(case((Session.outcome == "resolved", 1), else_=0)).label("resolved"), + func.sum(case((Session.outcome == "escalated", 1), else_=0)).label("escalated"), + func.sum(case((Session.outcome == "workaround", 1), else_=0)).label("workaround"), + func.sum(case((Session.outcome == "unresolved", 1), else_=0)).label("unresolved"), + ).select_from(Session) + if join_tree: + q = q.join(Tree, Session.tree_id == Tree.id) + + rows = await db.execute( + q.where(*base_filters) + .group_by(cast(Session.started_at, Date)) + .order_by(cast(Session.started_at, Date)) + ) + return [ + TimeSeriesPoint( + date=str(row.date), sessions=row.sessions, + resolved=int(row.resolved or 0), escalated=int(row.escalated or 0), + workaround=int(row.workaround or 0), unresolved=int(row.unresolved or 0), + ) + for row in rows.all() + ] + + +@router.get("/team", response_model=TeamAnalyticsResponse) +async def get_team_analytics( + period: str = Query("30d", pattern="^(7d|30d|90d)$"), + engineer_id: Optional[UUID] = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Team analytics - team_admin or super_admin only.""" + if not (current_user.is_team_admin or current_user.is_super_admin): + raise HTTPException(status_code=403, detail="Team admin access required") + + period_start = _get_period_start(period) + base_filters = [ + Session.started_at >= period_start, + Tree.account_id == current_user.account_id, + ] + if engineer_id: + base_filters.append(Session.user_id == engineer_id) + + summary = await _build_summary(db, base_filters, join_tree=True) + time_series = await _build_time_series(db, base_filters, join_tree=True) + + # Top flows (join Session->Tree) + top_flows_q = await db.execute( + select( + Tree.id, Tree.name, + func.count(Session.id).label("sessions"), + func.percentile_cont(0.5).within_group( + func.extract('epoch', Session.completed_at - Session.started_at) / 60 + ).label("median_duration"), + ) + .join(Tree, Session.tree_id == Tree.id) + .where(Session.started_at >= period_start, Tree.account_id == current_user.account_id) + .group_by(Tree.id, Tree.name) + .order_by(func.count(Session.id).desc()) + .limit(10) + ) + top_flows = [] + for row in top_flows_q.all(): + # Compute completion rate separately to avoid division by zero + completed_count_q = await db.execute( + select(func.count()).select_from(Session) + .where( + Session.tree_id == row.id, + Session.started_at >= period_start, + Session.completed_at.isnot(None), + ) + ) + completed_count = completed_count_q.scalar() or 0 + completion_rate = round(completed_count / row.sessions, 3) if row.sessions > 0 else 0.0 + top_flows.append( + TopFlow( + tree_id=str(row.id), name=row.name, sessions=row.sessions, + completion_rate=completion_rate, + median_duration_minutes=round(float(row.median_duration or 0), 1), + ) + ) + + # Top engineers (join Session->User + Session->Tree) + top_engineers_q = await db.execute( + select( + User.id, User.name, + func.count(Session.id).label("sessions"), + func.percentile_cont(0.5).within_group( + func.extract('epoch', Session.completed_at - Session.started_at) / 60 + ).label("median_duration"), + ) + .join(User, Session.user_id == User.id) + .join(Tree, Session.tree_id == Tree.id) + .where(Session.started_at >= period_start, Tree.account_id == current_user.account_id) + .group_by(User.id, User.name) + .order_by(func.count(Session.id).desc()) + .limit(10) + ) + top_engineers = [] + for row in top_engineers_q.all(): + completed_count_q = await db.execute( + select(func.count()).select_from(Session) + .join(Tree, Session.tree_id == Tree.id) + .where( + Session.user_id == row.id, + Session.started_at >= period_start, + Tree.account_id == current_user.account_id, + Session.completed_at.isnot(None), + ) + ) + completed_count = completed_count_q.scalar() or 0 + completion_rate = round(completed_count / row.sessions, 3) if row.sessions > 0 else 0.0 + top_engineers.append( + TopEngineer( + user_id=str(row.id), name=row.name or "Unknown", sessions=row.sessions, + completion_rate=completion_rate, + median_duration_minutes=round(float(row.median_duration or 0), 1), + ) + ) + + return TeamAnalyticsResponse( + summary=summary, time_series=time_series, + top_flows=top_flows, top_engineers=top_engineers, + ) + + +@router.get("/me", response_model=PersonalAnalyticsResponse) +async def get_personal_analytics( + period: str = Query("30d", pattern="^(7d|30d|90d)$"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Personal analytics - any authenticated user.""" + period_start = _get_period_start(period) + base_filters = [Session.started_at >= period_start, Session.user_id == current_user.id] + + summary = await _build_summary(db, base_filters, join_tree=False) + # Override active_engineers=1 for personal view + summary.active_engineers = 1 + time_series = await _build_time_series(db, base_filters, join_tree=False) + + # Top flows + top_flows_q = await db.execute( + select( + Tree.id, Tree.name, + func.count(Session.id).label("sessions"), + func.percentile_cont(0.5).within_group( + func.extract('epoch', Session.completed_at - Session.started_at) / 60 + ).label("median_duration"), + ) + .join(Tree, Session.tree_id == Tree.id) + .where(Session.started_at >= period_start, Session.user_id == current_user.id) + .group_by(Tree.id, Tree.name) + .order_by(func.count(Session.id).desc()) + .limit(10) + ) + top_flows = [] + for row in top_flows_q.all(): + completed_count_q = await db.execute( + select(func.count()).select_from(Session) + .where( + Session.tree_id == row.id, + Session.user_id == current_user.id, + Session.started_at >= period_start, + Session.completed_at.isnot(None), + ) + ) + completed_count = completed_count_q.scalar() or 0 + completion_rate = round(completed_count / row.sessions, 3) if row.sessions > 0 else 0.0 + top_flows.append( + TopFlow( + tree_id=str(row.id), name=row.name, sessions=row.sessions, + completion_rate=completion_rate, + median_duration_minutes=round(float(row.median_duration or 0), 1), + ) + ) + + return PersonalAnalyticsResponse( + summary=summary, time_series=time_series, top_flows=top_flows, + ) + + +@router.get("/flows/{tree_id}", response_model=FlowAnalyticsResponse) +async def get_flow_analytics( + tree_id: UUID, + period: str = Query("30d", pattern="^(7d|30d|90d)$"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Analytics for a specific flow.""" + # Verify tree exists + result = await db.execute(select(Tree).where(Tree.id == tree_id)) + tree = result.scalar_one_or_none() + if not tree: + raise HTTPException(status_code=404, detail="Flow not found") + + period_start = _get_period_start(period) + base_filters = [Session.started_at >= period_start, Session.tree_id == tree_id] + + summary = await _build_summary(db, base_filters, join_tree=False) + time_series = await _build_time_series(db, base_filters, join_tree=False) + + # CSAT stats + csat_q = await db.execute( + select(func.avg(SessionRating.rating), func.count()) + .where(SessionRating.tree_id == tree_id, SessionRating.created_at >= period_start) + ) + csat_row = csat_q.one() + avg_csat = round(float(csat_row[0]), 1) if csat_row[0] else None + total_ratings = csat_row[1] + + # Step feedback - compute from step_ratings for steps used in this tree's sessions + step_feedback: list[StepFeedbackSummary] = [] + + # Step dropoff analysis from session decisions JSONB + sessions_q = await db.execute( + select(Session.decisions, Session.completed_at) + .where(Session.tree_id == tree_id, Session.started_at >= period_start) + ) + sessions_data = sessions_q.all() + + node_visits: dict[str, int] = {} + node_dropoffs: dict[str, int] = {} + + for sess in sessions_data: + decisions = sess.decisions or [] + for decision in decisions: + node_id = decision.get("node_id", "") + if node_id: + node_visits[node_id] = node_visits.get(node_id, 0) + 1 + + # If session not completed, last decision node is a dropoff + if not sess.completed_at and decisions: + last_decision = decisions[-1] + last_node = last_decision.get("node_id", "") + if last_node: + node_dropoffs[last_node] = node_dropoffs.get(last_node, 0) + 1 + + # Build node title map from tree structure + node_title_map = _extract_node_titles(tree.tree_structure) + + # Build step feedback with dropoff data + for node_id in sorted(node_visits.keys()): + visits = node_visits.get(node_id, 0) + dropoffs = node_dropoffs.get(node_id, 0) + step_feedback.append(StepFeedbackSummary( + node_id=node_id, + node_title=node_title_map.get(node_id, "Unknown Step"), + helpful_yes=0, + helpful_no=0, + helpful_rate=0.0, + visit_count=visits, + dropoff_count=dropoffs, + dropoff_rate=round(dropoffs / visits, 3) if visits > 0 else 0.0, + )) + + # Sort by dropoff_rate descending + step_feedback.sort(key=lambda x: x.dropoff_rate, reverse=True) + + # Recent comments - ANONYMOUS (no user_name join) + comments_q = await db.execute( + select(SessionRating.rating, SessionRating.comment, SessionRating.created_at) + .where( + SessionRating.tree_id == tree_id, + SessionRating.comment.isnot(None), + SessionRating.comment != "", + ) + .order_by(SessionRating.created_at.desc()) + .limit(10) + ) + recent_comments = [ + FlowRatingItem( + rating=row.rating, + comment=row.comment, + created_at=row.created_at, + ) + for row in comments_q.all() + ] + + return FlowAnalyticsResponse( + summary=summary, avg_csat=avg_csat, total_ratings=total_ratings, + time_series=time_series, step_feedback=step_feedback, + recent_comments=recent_comments, + ) + + +def _extract_node_titles(tree_structure: dict) -> dict[str, str]: + """Recursively extract node_id -> title/question from tree structure.""" + titles = {} + + def walk(node): + if not isinstance(node, dict): + return + node_id = node.get("id", "") + title = node.get("title") or node.get("question") or "Unnamed" + if node_id: + titles[node_id] = title + for child in node.get("children", []): + walk(child) + + walk(tree_structure) + return titles diff --git a/backend/app/api/endpoints/ratings.py b/backend/app/api/endpoints/ratings.py new file mode 100644 index 00000000..23f07054 --- /dev/null +++ b/backend/app/api/endpoints/ratings.py @@ -0,0 +1,124 @@ +from uuid import UUID +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select, func +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 import User, Session, SessionRating +from app.models.step_library import StepLibrary, StepRating +from app.schemas.analytics import SessionRatingCreate, SessionRatingResponse, StepFeedbackCreate + +router = APIRouter(tags=["ratings"]) + + +@router.post("/sessions/{session_id}/rate", response_model=SessionRatingResponse, status_code=status.HTTP_201_CREATED) +async def rate_session( + session_id: UUID, + data: SessionRatingCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Submit a CSAT rating (1-5) for a completed session.""" + # Verify session exists and belongs to user + result = await db.execute(select(Session).where(Session.id == session_id)) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + if session.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not your session") + if not session.completed_at: + raise HTTPException(status_code=400, detail="Session not completed yet") + + # Check for duplicate + existing = await db.execute( + select(SessionRating).where(SessionRating.session_id == session_id) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Session already rated") + + rating = SessionRating( + session_id=session_id, + user_id=current_user.id, + tree_id=session.tree_id, + account_id=current_user.account_id, + rating=data.rating, + comment=data.comment, + ) + db.add(rating) + await db.commit() + await db.refresh(rating) + + return SessionRatingResponse( + id=str(rating.id), + session_id=str(rating.session_id), + rating=rating.rating, + comment=rating.comment, + created_at=rating.created_at, + ) + + +@router.post("/steps/{step_id}/feedback", status_code=status.HTTP_201_CREATED) +async def submit_step_feedback( + step_id: UUID, + data: StepFeedbackCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Submit thumbs up/down feedback for a step used in a session.""" + # Verify step exists + result = await db.execute(select(StepLibrary).where(StepLibrary.id == step_id)) + step = result.scalar_one_or_none() + if not step: + raise HTTPException(status_code=404, detail="Step not found") + + session_uuid = UUID(data.session_id) + + # Check for existing feedback for this step+user+session + existing_result = await db.execute( + select(StepRating).where( + StepRating.step_id == step_id, + StepRating.user_id == current_user.id, + StepRating.session_id == session_uuid, + ) + ) + existing = existing_result.scalar_one_or_none() + + if existing: + existing.was_helpful = data.was_helpful + resp_status = "updated" + else: + new_rating = StepRating( + step_id=step_id, + user_id=current_user.id, + session_id=session_uuid, + was_helpful=data.was_helpful, + # rating is nullable now — thumbs-only mode + ) + db.add(new_rating) + resp_status = "created" + + # Update aggregates on step_library + await _update_step_helpful_counts(db, step_id) + await db.commit() + + return {"step_id": str(step_id), "was_helpful": data.was_helpful, "status": resp_status} + + +async def _update_step_helpful_counts(db: AsyncSession, step_id: UUID): + """Recalculate helpful_yes and helpful_no on step_library.""" + yes_q = await db.execute( + select(func.count()).select_from(StepRating).where( + StepRating.step_id == step_id, StepRating.was_helpful == True + ) + ) + no_q = await db.execute( + select(func.count()).select_from(StepRating).where( + StepRating.step_id == step_id, StepRating.was_helpful == False + ) + ) + await db.execute( + StepLibrary.__table__.update() + .where(StepLibrary.id == step_id) + .values(helpful_yes=yes_q.scalar() or 0, helpful_no=no_q.scalar() or 0) + ) diff --git a/backend/app/api/endpoints/steps.py b/backend/app/api/endpoints/steps.py index 2e5d1c61..b188cf8d 100644 --- a/backend/app/api/endpoints/steps.py +++ b/backend/app/api/endpoints/steps.py @@ -554,6 +554,42 @@ async def delete_rating( return None +# Backward-compatible /ratings alias routes +@router.post("/{step_id}/ratings", response_model=StepRatingResponse, status_code=201, + include_in_schema=False) +async def rate_step_alias( + step_id: UUID, + rating_data: StepRatingCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Alias for POST /{step_id}/rate.""" + return await rate_step(step_id, rating_data, db, current_user) + + +@router.put("/{step_id}/ratings", response_model=StepRatingResponse, + include_in_schema=False) +async def update_rating_alias( + step_id: UUID, + rating_data: StepRatingUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Alias for PUT /{step_id}/rate.""" + return await update_rating(step_id, rating_data, db, current_user) + + +@router.delete("/{step_id}/ratings", status_code=204, + include_in_schema=False) +async def delete_rating_alias( + step_id: UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Alias for DELETE /{step_id}/rate.""" + return await delete_rating(step_id, db, current_user) + + @router.get("/{step_id}/reviews", response_model=list[StepRatingResponse]) async def get_reviews( step_id: UUID, diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 5537529f..2e96ddc7 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,6 +1,7 @@ from fastapi import APIRouter from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories +from app.api.endpoints import ratings, analytics api_router = APIRouter() @@ -25,3 +26,5 @@ api_router.include_router(webhooks.router) api_router.include_router(shares.router) api_router.include_router(shared.router) # Public endpoints (no auth) api_router.include_router(tree_markdown.router) +api_router.include_router(ratings.router) +api_router.include_router(analytics.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 7ba8536d..674f7627 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -17,6 +17,7 @@ from .refresh_token import RefreshToken from .audit_log import AuditLog from .password_reset_token import PasswordResetToken from .session_share import SessionShare, SessionShareView +from .session_rating import SessionRating from .account_limit_override import AccountLimitOverride from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride from .platform_setting import PlatformSetting @@ -47,6 +48,7 @@ __all__ = [ "PasswordResetToken", "SessionShare", "SessionShareView", + "SessionRating", "AccountLimitOverride", "FeatureFlag", "PlanFeatureDefault", diff --git a/backend/app/models/session_rating.py b/backend/app/models/session_rating.py new file mode 100644 index 00000000..63d196be --- /dev/null +++ b/backend/app/models/session_rating.py @@ -0,0 +1,46 @@ +import uuid +from datetime import datetime, timezone +from typing import Optional, TYPE_CHECKING +from sqlalchemy import String, DateTime, Integer, CheckConstraint, UniqueConstraint, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.user import User + from app.models.session import Session + from app.models.tree import Tree + + +class SessionRating(Base): + __tablename__ = "session_ratings" + __table_args__ = ( + CheckConstraint("rating >= 1 AND rating <= 5", name="ck_session_ratings_rating_range"), + UniqueConstraint("session_id", name="uq_session_ratings_session_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + session_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + tree_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"), nullable=False + ) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False + ) + rating: Mapped[int] = mapped_column(Integer, nullable=False) + comment: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False + ) + + # Relationships + session: Mapped["Session"] = relationship("Session", foreign_keys=[session_id]) + user: Mapped["User"] = relationship("User", foreign_keys=[user_id]) + tree: Mapped["Tree"] = relationship("Tree", foreign_keys=[tree_id]) diff --git a/backend/app/models/step_library.py b/backend/app/models/step_library.py index ae2b319b..a3f23488 100644 --- a/backend/app/models/step_library.py +++ b/backend/app/models/step_library.py @@ -125,7 +125,7 @@ class StepRating(Base): ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) - rating: Mapped[int] = mapped_column(Integer, nullable=False) + rating: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) was_helpful: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True) review_text: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) is_verified_use: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) diff --git a/backend/app/schemas/analytics.py b/backend/app/schemas/analytics.py new file mode 100644 index 00000000..0cb9651f --- /dev/null +++ b/backend/app/schemas/analytics.py @@ -0,0 +1,111 @@ +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field + + +# --- Session Rating Schemas --- + +class SessionRatingCreate(BaseModel): + rating: int = Field(..., ge=1, le=5) + comment: Optional[str] = Field(None, max_length=500) + + +class SessionRatingResponse(BaseModel): + id: str + session_id: str + rating: int + comment: Optional[str] + created_at: datetime + + model_config = {"from_attributes": True} + + +class FlowRatingItem(BaseModel): + """Anonymous feedback item — no user_name for privacy.""" + rating: int + comment: Optional[str] + created_at: datetime + + +# --- Step Feedback Schema --- + +class StepFeedbackCreate(BaseModel): + session_id: str + was_helpful: bool + + +# --- Analytics Response Schemas --- + +class OutcomeBreakdown(BaseModel): + resolved: int = 0 + escalated: int = 0 + workaround: int = 0 + unresolved: int = 0 + + +class AnalyticsSummary(BaseModel): + total_sessions: int + completed_sessions: int + completion_rate: float + median_duration_minutes: float + active_engineers: int = 0 + outcome_breakdown: OutcomeBreakdown + + +class TimeSeriesPoint(BaseModel): + date: str + sessions: int = 0 + resolved: int = 0 + escalated: int = 0 + workaround: int = 0 + unresolved: int = 0 + + +class TopFlow(BaseModel): + tree_id: str + name: str + sessions: int + completion_rate: float + median_duration_minutes: float + avg_csat: Optional[float] = None + + +class TopEngineer(BaseModel): + user_id: str + name: str + sessions: int + completion_rate: float + median_duration_minutes: float + + +class TeamAnalyticsResponse(BaseModel): + summary: AnalyticsSummary + time_series: list[TimeSeriesPoint] + top_flows: list[TopFlow] + top_engineers: list[TopEngineer] + + +class PersonalAnalyticsResponse(BaseModel): + summary: AnalyticsSummary + time_series: list[TimeSeriesPoint] + top_flows: list[TopFlow] + + +class StepFeedbackSummary(BaseModel): + node_id: str + node_title: str + helpful_yes: int + helpful_no: int + helpful_rate: float + visit_count: int = 0 + dropoff_count: int = 0 + dropoff_rate: float = 0.0 + + +class FlowAnalyticsResponse(BaseModel): + summary: AnalyticsSummary + avg_csat: Optional[float] + total_ratings: int + time_series: list[TimeSeriesPoint] + step_feedback: list[StepFeedbackSummary] + recent_comments: list[FlowRatingItem] diff --git a/backend/tests/test_analytics.py b/backend/tests/test_analytics.py new file mode 100644 index 00000000..33574b72 --- /dev/null +++ b/backend/tests/test_analytics.py @@ -0,0 +1,137 @@ +import pytest +from httpx import AsyncClient + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +async def team_admin(client: AsyncClient, test_db): + """Create a team admin user (gets own account via registration).""" + from uuid import UUID as PyUUID + from sqlalchemy import select + from app.models.user import User + + data = { + "email": "teamadmin@example.com", + "password": "TeamAdmin123!", + "name": "Team Admin" + } + response = await client.post("/api/v1/auth/register", json=data) + assert response.status_code in (200, 201), f"Failed: {response.text}" + + user_id = PyUUID(response.json()["id"]) + result = await test_db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one() + user.is_team_admin = True + await test_db.commit() + + return {"email": data["email"], "password": data["password"], "user_data": response.json()} + + +@pytest.fixture +async def team_admin_headers(client: AsyncClient, team_admin: dict): + """Auth headers for team admin.""" + response = await client.post( + "/api/v1/auth/login/json", + json={"email": team_admin["email"], "password": team_admin["password"]}, + ) + assert response.status_code == 200 + return {"Authorization": f"Bearer {response.json()['access_token']}"} + + +async def test_team_analytics_success(client: AsyncClient, admin_auth_headers: dict): + """Super admin can access team analytics.""" + response = await client.get( + "/api/v1/analytics/team?period=30d", + headers=admin_auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert "summary" in data + assert "time_series" in data + assert "top_flows" in data + assert "top_engineers" in data + assert "total_sessions" in data["summary"] + assert "completion_rate" in data["summary"] + assert "median_duration_minutes" in data["summary"] + assert "active_engineers" in data["summary"] + + +async def test_team_analytics_team_admin(client: AsyncClient, team_admin_headers: dict): + """Team admin can also access team analytics.""" + response = await client.get( + "/api/v1/analytics/team", + headers=team_admin_headers, + ) + assert response.status_code == 200 + + +async def test_team_analytics_forbidden_for_engineer(client: AsyncClient, auth_headers: dict): + """Regular engineers cannot access team analytics.""" + response = await client.get( + "/api/v1/analytics/team", + headers=auth_headers, + ) + assert response.status_code == 403 + + +async def test_personal_analytics_success(client: AsyncClient, auth_headers: dict): + """Any user can access personal analytics.""" + response = await client.get( + "/api/v1/analytics/me?period=30d", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert "summary" in data + assert "time_series" in data + assert "top_flows" in data + assert "median_duration_minutes" in data["summary"] + + +async def test_personal_analytics_empty(client: AsyncClient, auth_headers: dict): + """Personal analytics with no sessions returns zeroes.""" + response = await client.get( + "/api/v1/analytics/me?period=7d", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["summary"]["total_sessions"] == 0 + assert data["summary"]["completion_rate"] == 0.0 + assert data["summary"]["median_duration_minutes"] == 0.0 + assert data["summary"]["active_engineers"] == 1 # always 1 for personal + + +async def test_flow_analytics_success(client: AsyncClient, auth_headers: dict, test_tree: dict): + """Can access analytics for a visible flow.""" + response = await client.get( + f"/api/v1/analytics/flows/{test_tree['id']}", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert "summary" in data + assert "step_feedback" in data + assert "recent_comments" in data + assert "avg_csat" in data + assert "total_ratings" in data + + +async def test_flow_analytics_404(client: AsyncClient, auth_headers: dict): + """Non-existent flow returns 404.""" + import uuid + response = await client.get( + f"/api/v1/analytics/flows/{uuid.uuid4()}", + headers=auth_headers, + ) + assert response.status_code == 404 + + +async def test_invalid_period_rejected(client: AsyncClient, auth_headers: dict): + """Invalid period values are rejected.""" + response = await client.get( + "/api/v1/analytics/me?period=1y", + headers=auth_headers, + ) + assert response.status_code == 422 diff --git a/backend/tests/test_ratings.py b/backend/tests/test_ratings.py new file mode 100644 index 00000000..8f6d3f66 --- /dev/null +++ b/backend/tests/test_ratings.py @@ -0,0 +1,215 @@ +import pytest +from httpx import AsyncClient + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +async def test_session(client: AsyncClient, auth_headers: dict, test_tree: dict): + """Create a test session from the test tree.""" + response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers, + ) + assert response.status_code == 201, f"Failed to create session: {response.text}" + return response.json() + + +@pytest.fixture +async def completed_session(client: AsyncClient, auth_headers: dict, test_session: dict): + """Complete a session so it can be rated.""" + response = await client.post( + f"/api/v1/sessions/{test_session['id']}/complete", + json={"outcome": "resolved", "outcome_notes": "Test resolved"}, + headers=auth_headers, + ) + assert response.status_code == 200, f"Failed to complete session: {response.text}" + return response.json() + + +async def test_rate_session_success(client: AsyncClient, auth_headers: dict, completed_session: dict): + """Rate a completed session with CSAT score.""" + response = await client.post( + f"/api/v1/sessions/{completed_session['id']}/rate", + json={"rating": 4, "comment": "Very helpful flow"}, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["rating"] == 4 + assert data["comment"] == "Very helpful flow" + + +async def test_rate_session_no_comment(client: AsyncClient, auth_headers: dict, completed_session: dict): + """Rate without a comment.""" + response = await client.post( + f"/api/v1/sessions/{completed_session['id']}/rate", + json={"rating": 5}, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["rating"] == 5 + assert data["comment"] is None + + +async def test_rate_session_duplicate(client: AsyncClient, auth_headers: dict, completed_session: dict): + """Cannot rate same session twice.""" + await client.post( + f"/api/v1/sessions/{completed_session['id']}/rate", + json={"rating": 4}, + headers=auth_headers, + ) + response = await client.post( + f"/api/v1/sessions/{completed_session['id']}/rate", + json={"rating": 5}, + headers=auth_headers, + ) + assert response.status_code == 409 + + +async def test_rate_session_invalid_rating(client: AsyncClient, auth_headers: dict, completed_session: dict): + """Rating must be 1-5.""" + response = await client.post( + f"/api/v1/sessions/{completed_session['id']}/rate", + json={"rating": 6}, + headers=auth_headers, + ) + assert response.status_code == 422 + + +async def test_rate_incomplete_session(client: AsyncClient, auth_headers: dict, test_session: dict): + """Cannot rate a session that hasn't been completed.""" + response = await client.post( + f"/api/v1/sessions/{test_session['id']}/rate", + json={"rating": 4}, + headers=auth_headers, + ) + assert response.status_code == 400 + + +# --- Step Feedback Tests --- + +@pytest.fixture +async def test_step(client: AsyncClient, auth_headers: dict): + """Create a step in the step library.""" + response = await client.post( + "/api/v1/steps", + json={ + "title": "Test Step", + "step_type": "action", + "content": { + "instructions": "Run the diagnostic command", + "commands": [{"label": "Echo test", "command": "echo hello"}] + }, + }, + headers=auth_headers, + ) + assert response.status_code == 201, f"Failed to create step: {response.text}" + return response.json() + + +async def test_step_feedback_thumbs_up(client: AsyncClient, auth_headers: dict, test_step: dict, test_session: dict): + """Submit thumbs-up feedback for a step.""" + response = await client.post( + f"/api/v1/steps/{test_step['id']}/feedback", + json={"session_id": test_session["id"], "was_helpful": True}, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["was_helpful"] is True + assert data["status"] == "created" + + +async def test_step_feedback_thumbs_down(client: AsyncClient, auth_headers: dict, test_step: dict, test_session: dict): + """Submit thumbs-down feedback for a step.""" + response = await client.post( + f"/api/v1/steps/{test_step['id']}/feedback", + json={"session_id": test_session["id"], "was_helpful": False}, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["was_helpful"] is False + assert data["status"] == "created" + + +async def test_step_feedback_toggle(client: AsyncClient, auth_headers: dict, test_step: dict, test_session: dict): + """Submitting again for same step+session updates the rating.""" + await client.post( + f"/api/v1/steps/{test_step['id']}/feedback", + json={"session_id": test_session["id"], "was_helpful": True}, + headers=auth_headers, + ) + response = await client.post( + f"/api/v1/steps/{test_step['id']}/feedback", + json={"session_id": test_session["id"], "was_helpful": False}, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["was_helpful"] is False + assert data["status"] == "updated" + + +async def test_step_feedback_not_found(client: AsyncClient, auth_headers: dict, test_session: dict): + """Feedback on non-existent step returns 404.""" + import uuid + fake_id = str(uuid.uuid4()) + response = await client.post( + f"/api/v1/steps/{fake_id}/feedback", + json={"session_id": test_session["id"], "was_helpful": True}, + headers=auth_headers, + ) + assert response.status_code == 404 + + +# --- /ratings Alias Route Tests --- + +async def test_ratings_alias_post(client: AsyncClient, auth_headers: dict, test_step: dict): + """POST /steps/{id}/ratings works as alias for /rate.""" + response = await client.post( + f"/api/v1/steps/{test_step['id']}/ratings", + json={"rating": 4, "was_helpful": True}, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["rating"] == 4 + + +async def test_ratings_alias_put(client: AsyncClient, auth_headers: dict, test_step: dict): + """PUT /steps/{id}/ratings works as alias for /rate.""" + # First create a rating + await client.post( + f"/api/v1/steps/{test_step['id']}/rate", + json={"rating": 3, "was_helpful": True}, + headers=auth_headers, + ) + # Update via alias + response = await client.put( + f"/api/v1/steps/{test_step['id']}/ratings", + json={"rating": 5}, + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["rating"] == 5 + + +async def test_ratings_alias_delete(client: AsyncClient, auth_headers: dict, test_step: dict): + """DELETE /steps/{id}/ratings works as alias for /rate.""" + # First create a rating + await client.post( + f"/api/v1/steps/{test_step['id']}/rate", + json={"rating": 4, "was_helpful": True}, + headers=auth_headers, + ) + # Delete via alias + response = await client.delete( + f"/api/v1/steps/{test_step['id']}/ratings", + headers=auth_headers, + ) + assert response.status_code == 204 diff --git a/docs/plans/2026-02-15-analytics-feedback-design.md b/docs/plans/2026-02-15-analytics-feedback-design.md new file mode 100644 index 00000000..71b8e7a5 --- /dev/null +++ b/docs/plans/2026-02-15-analytics-feedback-design.md @@ -0,0 +1,294 @@ +# Analytics & User Feedback — Design Document + +> **Date:** February 15, 2026 +> **Status:** Approved +> **Audience:** Team admins + individual engineers (both equally) + +--- + +## Goal + +Add analytics dashboards and user feedback systems to ResolutionFlow so MSP managers can measure team productivity and flow effectiveness, engineers can track their own performance, and flow authors get actionable feedback to improve their troubleshooting trees. + +## Architecture + +Live queries against existing PostgreSQL tables (sessions, trees, step_library) with one new table (session_ratings). No materialized views or external analytics services — direct aggregation queries with proper indexes. Time-series data returned as daily-bucketed arrays for Recharts visualization on the frontend. + +## Tech Stack Additions + +- **Backend:** New endpoint modules (`analytics.py`, `ratings.py`), one Alembic migration +- **Frontend:** Recharts (charting), two new pages, one new modal, inline step feedback components +- **No new infrastructure** — runs on existing PostgreSQL + Railway deployment + +--- + +## 1. Feedback System Design + +### Step-Level Feedback: Thumbs Up / Thumbs Down + +**When:** Inline during session navigation, always visible on each step. + +**UX:** +- Small thumb-up and thumb-down icons displayed on each step in TreeNavigationPage and ProceduralNavigationPage +- Non-intrusive: muted icons that highlight on selection +- First-time tooltip: "Rate this step to help improve flows" (dismissible, shown once) +- After rating: selected thumb highlights (green for up, red for down), other thumb dims +- Tapping the same thumb again un-rates (toggle behavior) + +**Data model:** +- Uses existing `step_ratings` table with `was_helpful` boolean +- One rating per user per step per session (unique constraint on step_id + user_id + session_id) +- Updates `step_library.helpful_yes` / `helpful_no` aggregate counts + +**Endpoint:** +- `POST /steps/{step_id}/feedback` — body: `{ session_id, was_helpful: true|false }` +- `DELETE /steps/{step_id}/feedback/{session_id}` — un-rate + +### Flow-Level Feedback: CSAT 1-5 + Optional Comment + +**When:** After session completion, prompted after the SessionOutcomeModal. + +**UX:** +- Modal with 1-5 star selector (or numbered buttons) +- Optional comment textarea (500 char max) +- "Skip" button to dismiss without rating +- Shown once per completed session + +**Data model:** +- New `session_ratings` table: + - `id` UUID PK + - `session_id` FK unique (one rating per session) + - `user_id` FK + - `tree_id` FK (denormalized for aggregation) + - `account_id` FK (denormalized for team scoping) + - `rating` Integer 1-5 (CHECK constraint) + - `comment` String(500), nullable + - `created_at` DateTime(timezone=True) + +**Endpoint:** +- `POST /sessions/{session_id}/rate` — body: `{ rating: 1-5, comment?: string }` +- `GET /trees/{tree_id}/ratings` — paginated list of ratings/comments for flow authors + +--- + +## 2. Analytics Endpoints + +All analytics endpoints accept `?period=7d|30d|90d` (default 30d) and return data scoped to the user's account. + +### Team Analytics — `GET /analytics/team` + +**Access:** team_admin or super_admin only. + +**Response:** +```json +{ + "summary": { + "total_sessions": 247, + "completed_sessions": 198, + "completion_rate": 0.801, + "avg_duration_minutes": 12.4, + "active_engineers": 8, + "outcome_breakdown": { + "resolved": 142, + "escalated": 31, + "workaround": 18, + "unresolved": 7 + } + }, + "time_series": [ + { "date": "2026-02-01", "sessions": 12, "resolved": 8, "escalated": 2, "workaround": 1, "unresolved": 1 }, + ... + ], + "top_flows": [ + { "tree_id": "...", "name": "DNS Resolution", "sessions": 42, "completion_rate": 0.88, "avg_duration_minutes": 8.2, "avg_csat": 4.1 }, + ... + ], + "top_engineers": [ + { "user_id": "...", "name": "Jane Smith", "sessions": 34, "completion_rate": 0.91, "avg_duration_minutes": 10.1 }, + ... + ] +} +``` + +**Optional filter:** `?engineer_id=` to scope to one engineer. + +### Personal Analytics — `GET /analytics/me` + +**Access:** Any authenticated user. + +**Response:** Same shape as team analytics but scoped to the requesting user only. No engineer leaderboard — replaced with "my top flows" list. + +### Flow Analytics — `GET /analytics/flows/{tree_id}` + +**Access:** Anyone who can view the flow. + +**Response:** +```json +{ + "summary": { + "total_sessions": 42, + "completion_rate": 0.88, + "avg_duration_minutes": 8.2, + "avg_csat": 4.1, + "total_ratings": 28, + "outcome_breakdown": { ... } + }, + "time_series": [ + { "date": "2026-02-01", "sessions": 3, "avg_duration_minutes": 7.5 }, + ... + ], + "step_feedback": [ + { "node_id": "abc", "node_title": "Check DNS Settings", "helpful_yes": 18, "helpful_no": 2, "helpful_rate": 0.9 }, + ... + ], + "recent_comments": [ + { "rating": 5, "comment": "Very helpful flow", "user_name": "John", "created_at": "..." }, + ... + ] +} +``` + +--- + +## 3. Frontend Pages + +### Team Analytics Page — `/analytics` + +**Access:** team_admin+ (hidden from engineers/viewers in nav). + +**Layout:** +- Page header: "Team Analytics" + period dropdown (7d / 30d / 90d) +- Row 1: Stat cards — Total Sessions, Completion Rate, Avg Duration, Active Engineers +- Row 2: Time-series chart (sessions per day, stacked by outcome) — Recharts AreaChart +- Row 3: Two columns: + - Left: Flow Leaderboard table (top flows by usage, with completion rate + avg duration + CSAT) + - Right: Engineer Leaderboard table (top engineers by session count, with success rate + avg duration) + +### My Analytics Page — `/analytics/me` + +**Access:** Any authenticated user. + +**Layout:** +- Page header: "My Analytics" + period dropdown +- Row 1: Stat cards — My Sessions, My Completion Rate, My Avg Duration, My Outcome Split +- Row 2: Sessions-per-day line chart +- Row 3: Two columns: + - Left: My Top Flows table (most-used flows with personal stats) + - Right: Outcome distribution donut chart (Recharts PieChart) + +### Flow Analytics Panel + +**Location:** New tab or expandable section on tree detail/editor views. + +**Layout:** +- Summary stat cards: Usage, Completion Rate, Avg Duration, CSAT +- Session trend mini-chart (sparkline or small area chart) +- Step feedback table: each step with helpful rate bar + thumbs count +- Recent CSAT comments list (latest 5-10) + +### Navigation + +- Sidebar: Add "Analytics" nav item with BarChart3 icon between "Sessions" and "Exports" +- Team admins see `/analytics` (team view) as default +- Engineers see `/analytics/me` as default +- Both can navigate between views if they have permission + +--- + +## 4. Database Migration + +**New table: `session_ratings`** + +```sql +CREATE TABLE session_ratings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tree_id UUID NOT NULL REFERENCES trees(id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + comment VARCHAR(500), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (session_id) +); +``` + +**New indexes for analytics queries:** + +```sql +CREATE INDEX ix_session_ratings_tree_created ON session_ratings(tree_id, created_at); +CREATE INDEX ix_session_ratings_account_created ON session_ratings(account_id, created_at); +CREATE INDEX ix_sessions_account_completed ON sessions(account_id, completed_at); +CREATE INDEX ix_sessions_account_tree_completed ON sessions(account_id, tree_id, completed_at); +CREATE INDEX ix_step_ratings_step_helpful ON step_ratings(step_id, was_helpful); +``` + +**Modify `step_ratings` usage:** +- Existing `rating` (1-5) and `review_text` columns remain in DB but are no longer populated +- Only `was_helpful` boolean is used going forward +- Add `session_id` to unique constraint if not already present: `UNIQUE(step_id, user_id, session_id)` + +--- + +## 5. Charting Library + +**Recharts** (`recharts` npm package) +- React-native, composable components +- Supports: AreaChart (time-series), BarChart (comparisons), PieChart (outcome donut), LineChart (trends) +- Lightweight (~45KB gzipped) +- Dark theme compatible via custom colors matching our design tokens + +**Chart color palette** (matching design system): +- Primary: `hsl(243, 75%, 59%)` (purple — matches `--primary`) +- Resolved: `#34d399` (emerald-400) +- Escalated: `#f87171` (red-400) +- Workaround: `#fbbf24` (yellow-400) +- Unresolved: `#94a3b8` (slate-400) + +--- + +## 6. Step Feedback UX Detail + +**Inline thumbs placement:** +- Positioned at the bottom of each step card, right-aligned +- Two icons: ThumbsUp and ThumbsDown from Lucide +- Default state: `text-muted-foreground` (subtle, not distracting) +- Hover: icon scales slightly, tooltip appears +- Selected up: `text-emerald-400` with subtle fill +- Selected down: `text-red-400` with subtle fill +- Toggle: clicking selected thumb un-selects it + +**First-time hint:** +- On the first session where thumbs are available, show a subtle inline note below the first step: "New: Rate steps with thumbs to help improve flows" +- Store dismissal in localStorage +- Auto-dismisses after first thumb interaction + +**CSAT Modal (post-completion):** +- Appears after SessionOutcomeModal closes +- Five numbered buttons (1-5) or star icons in a row +- Label: "How would you rate this flow?" +- Sublabel: "Your feedback helps flow authors improve" +- Optional textarea: "Any comments? (optional)" +- Buttons: "Submit" (primary) + "Skip" (text link) +- localStorage tracks which sessions have been rated to prevent re-prompting + +--- + +## 7. Scope & Non-Goals + +**In scope:** +- Team analytics dashboard with time-series charts +- Personal analytics dashboard +- Flow-level analytics panel +- Step thumbs up/down inline feedback +- Flow CSAT 1-5 + comment at session end +- Period filtering (7d/30d/90d) + +**Not in scope (future):** +- Real-time streaming analytics +- Export analytics to CSV/PDF +- Custom date range picker (just preset periods for v1) +- Comparison mode (this period vs. last period) +- Automation adoption metrics +- Client/ticket correlation analytics +- Email digest/reports diff --git a/docs/plans/2026-02-15-analytics-feedback-implementation.md b/docs/plans/2026-02-15-analytics-feedback-implementation.md new file mode 100644 index 00000000..6bd710ee --- /dev/null +++ b/docs/plans/2026-02-15-analytics-feedback-implementation.md @@ -0,0 +1,1626 @@ +# Analytics & User Feedback — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add team analytics, personal analytics, flow analytics dashboards and a two-tier feedback system (step thumbs up/down + flow CSAT 1-5) to ResolutionFlow. + +**Architecture:** Live aggregation queries against existing PostgreSQL tables plus one new `session_ratings` table. Four new API endpoint groups (`/analytics/team`, `/analytics/me`, `/analytics/flows/{id}`, `/sessions/{id}/rate`). Recharts for time-series visualization. Step feedback reuses existing `step_ratings` table with simplified thumbs-only input. + +**Tech Stack:** Python FastAPI, SQLAlchemy 2.0 async, Alembic, React 19, TypeScript, Recharts, Tailwind CSS v3 + +--- + +## Reference: Design Document + +See `docs/plans/2026-02-15-analytics-feedback-design.md` for full design rationale and endpoint response shapes. + +## Reference: Existing Code + +- Step rating endpoints already exist: `backend/app/api/endpoints/steps.py:427-618` (POST/PUT/DELETE `/steps/{id}/rate` + aggregation helper) +- Session model: `backend/app/models/session.py` — has `outcome`, `completed_at`, `started_at`, `tree_id`, `user_id` columns +- Tree model: `backend/app/models/tree.py` — has `usage_count`, `account_id` +- Session does NOT have `account_id` — must join through `trees.account_id` or `users.account_id` +- Latest migration: `038_remove_workspace_system.py` +- Route registration: `backend/app/api/router.py` — import module + `api_router.include_router()` +- Frontend routes: `frontend/src/router.tsx` — lazy imports + Suspense wrappers +- Sidebar nav: `frontend/src/components/layout/Sidebar.tsx:119-153` +- Permissions: `frontend/src/hooks/usePermissions.ts` — `isSuperAdmin`, `isAccountOwner`, `effectiveRole` + +--- + +## Task 1: Database Migration + +**Files:** +- Create: `backend/alembic/versions/039_add_session_ratings_and_analytics_indexes.py` + +**Step 1: Generate migration** + +```bash +cd backend +``` + +Create migration file manually (autogenerate won't pick up indexes properly): + +```python +"""Add session_ratings table and analytics indexes + +Revision ID: 039 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +revision = '039_add_session_ratings_and_analytics_indexes' +down_revision = '038_remove_workspace_system' + +def upgrade(): + # New session_ratings table + op.create_table( + 'session_ratings', + sa.Column('id', UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), primary_key=True), + sa.Column('session_id', UUID(as_uuid=True), sa.ForeignKey('sessions.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('tree_id', UUID(as_uuid=True), sa.ForeignKey('trees.id', ondelete='CASCADE'), nullable=False), + sa.Column('account_id', UUID(as_uuid=True), sa.ForeignKey('accounts.id', ondelete='CASCADE'), nullable=False), + sa.Column('rating', sa.Integer, nullable=False), + sa.Column('comment', sa.String(500), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), + sa.CheckConstraint('rating >= 1 AND rating <= 5', name='ck_session_ratings_rating_range'), + sa.UniqueConstraint('session_id', name='uq_session_ratings_session_id'), + ) + + # Analytics indexes + op.create_index('ix_session_ratings_tree_created', 'session_ratings', ['tree_id', 'created_at']) + op.create_index('ix_session_ratings_account_created', 'session_ratings', ['account_id', 'created_at']) + op.create_index('ix_sessions_completed', 'sessions', ['completed_at']) + op.create_index('ix_step_ratings_step_helpful', 'step_ratings', ['step_id', 'was_helpful']) + + # Make step_ratings.rating column nullable (thumbs-only mode) + op.alter_column('step_ratings', 'rating', nullable=True) + + # Drop old unique constraint on step_ratings (step_id, user_id) and replace with (step_id, user_id, session_id) + op.drop_constraint('uq_step_rating_per_user', 'step_ratings', type_='unique') + op.create_unique_constraint('uq_step_rating_per_user_session', 'step_ratings', ['step_id', 'user_id', 'session_id']) + +def downgrade(): + op.drop_constraint('uq_step_rating_per_user_session', 'step_ratings', type_='unique') + op.create_unique_constraint('uq_step_rating_per_user', 'step_ratings', ['step_id', 'user_id']) + op.alter_column('step_ratings', 'rating', nullable=False) + op.drop_index('ix_step_ratings_step_helpful', 'step_ratings') + op.drop_index('ix_sessions_completed', 'sessions') + op.drop_index('ix_session_ratings_account_created', 'session_ratings') + op.drop_index('ix_session_ratings_tree_created', 'session_ratings') + op.drop_table('session_ratings') +``` + +**Step 2: Run the migration** + +```bash +cd backend && alembic upgrade head +``` + +Expected: Migration applies successfully with no errors. + +**Step 3: Verify table exists** + +```bash +docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d session_ratings" +``` + +Expected: Table with columns id, session_id, user_id, tree_id, account_id, rating, comment, created_at. + +**Step 4: Commit** + +```bash +git add backend/alembic/versions/039_* +git commit -m "feat: add session_ratings table and analytics indexes" +``` + +--- + +## Task 2: SessionRating Model + Schemas + +**Files:** +- Create: `backend/app/models/session_rating.py` +- Modify: `backend/app/models/__init__.py` +- Create: `backend/app/schemas/analytics.py` +- Modify: `backend/app/schemas/__init__.py` (if it exists as a barrel export) + +**Step 1: Create the SessionRating model** + +Create `backend/app/models/session_rating.py`: + +```python +import uuid +from datetime import datetime, timezone +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, CheckConstraint, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from app.core.database import Base + + +class SessionRating(Base): + __tablename__ = "session_ratings" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + session_id = Column(UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + tree_id = Column(UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"), nullable=False) + account_id = Column(UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False) + rating = Column(Integer, nullable=False) + comment = Column(String(500), nullable=True) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + + __table_args__ = ( + CheckConstraint("rating >= 1 AND rating <= 5", name="ck_session_ratings_rating_range"), + UniqueConstraint("session_id", name="uq_session_ratings_session_id"), + ) + + # Relationships + session = relationship("Session", foreign_keys=[session_id]) + user = relationship("User", foreign_keys=[user_id]) + tree = relationship("Tree", foreign_keys=[tree_id]) +``` + +**Step 2: Register in models/__init__.py** + +Add to `backend/app/models/__init__.py`: + +```python +from .session_rating import SessionRating +``` + +And add `"SessionRating"` to the `__all__` list. + +**Step 3: Create analytics schemas** + +Create `backend/app/schemas/analytics.py`: + +```python +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field + + +# --- Session Rating Schemas --- + +class SessionRatingCreate(BaseModel): + rating: int = Field(..., ge=1, le=5) + comment: Optional[str] = Field(None, max_length=500) + + +class SessionRatingResponse(BaseModel): + id: str + session_id: str + rating: int + comment: Optional[str] + created_at: datetime + + model_config = {"from_attributes": True} + + +class FlowRatingItem(BaseModel): + rating: int + comment: Optional[str] + user_name: Optional[str] + created_at: datetime + + +# --- Step Feedback Schema --- + +class StepFeedbackCreate(BaseModel): + session_id: str + was_helpful: bool + + +# --- Analytics Response Schemas --- + +class OutcomeBreakdown(BaseModel): + resolved: int = 0 + escalated: int = 0 + workaround: int = 0 + unresolved: int = 0 + + +class AnalyticsSummary(BaseModel): + total_sessions: int + completed_sessions: int + completion_rate: float + avg_duration_minutes: float + outcome_breakdown: OutcomeBreakdown + + +class TimeSeriesPoint(BaseModel): + date: str + sessions: int = 0 + resolved: int = 0 + escalated: int = 0 + workaround: int = 0 + unresolved: int = 0 + + +class TopFlow(BaseModel): + tree_id: str + name: str + sessions: int + completion_rate: float + avg_duration_minutes: float + avg_csat: Optional[float] = None + + +class TopEngineer(BaseModel): + user_id: str + name: str + sessions: int + completion_rate: float + avg_duration_minutes: float + + +class TeamAnalyticsResponse(BaseModel): + summary: AnalyticsSummary + time_series: list[TimeSeriesPoint] + top_flows: list[TopFlow] + top_engineers: list[TopEngineer] + + +class PersonalAnalyticsResponse(BaseModel): + summary: AnalyticsSummary + time_series: list[TimeSeriesPoint] + top_flows: list[TopFlow] + + +class StepFeedbackSummary(BaseModel): + node_id: str + node_title: str + helpful_yes: int + helpful_no: int + helpful_rate: float + + +class FlowAnalyticsResponse(BaseModel): + summary: AnalyticsSummary + avg_csat: Optional[float] + total_ratings: int + time_series: list[TimeSeriesPoint] + step_feedback: list[StepFeedbackSummary] + recent_comments: list[FlowRatingItem] +``` + +**Step 4: Verify import works** + +```bash +cd backend && python -c "from app.models.session_rating import SessionRating; print('OK')" +``` + +**Step 5: Commit** + +```bash +git add backend/app/models/session_rating.py backend/app/models/__init__.py backend/app/schemas/analytics.py +git commit -m "feat: add SessionRating model and analytics schemas" +``` + +--- + +## Task 3: Session Rating Endpoint + +**Files:** +- Create: `backend/app/api/endpoints/ratings.py` +- Modify: `backend/app/api/router.py` +- Create: `backend/tests/test_ratings.py` + +**Step 1: Write tests** + +Create `backend/tests/test_ratings.py`: + +```python +import pytest +from httpx import AsyncClient + +pytestmark = pytest.mark.asyncio + + +async def test_rate_session_success(client: AsyncClient, auth_headers: dict, completed_session_id: str): + """Rate a completed session with CSAT score.""" + response = await client.post( + f"/api/v1/sessions/{completed_session_id}/rate", + json={"rating": 4, "comment": "Very helpful flow"}, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["rating"] == 4 + assert data["comment"] == "Very helpful flow" + + +async def test_rate_session_duplicate(client: AsyncClient, auth_headers: dict, completed_session_id: str): + """Cannot rate same session twice.""" + await client.post( + f"/api/v1/sessions/{completed_session_id}/rate", + json={"rating": 4}, + headers=auth_headers, + ) + response = await client.post( + f"/api/v1/sessions/{completed_session_id}/rate", + json={"rating": 5}, + headers=auth_headers, + ) + assert response.status_code == 409 + + +async def test_rate_session_invalid_rating(client: AsyncClient, auth_headers: dict, completed_session_id: str): + """Rating must be 1-5.""" + response = await client.post( + f"/api/v1/sessions/{completed_session_id}/rate", + json={"rating": 6}, + headers=auth_headers, + ) + assert response.status_code == 422 + + +async def test_rate_incomplete_session(client: AsyncClient, auth_headers: dict, active_session_id: str): + """Cannot rate a session that hasn't been completed.""" + response = await client.post( + f"/api/v1/sessions/{active_session_id}/rate", + json={"rating": 4}, + headers=auth_headers, + ) + assert response.status_code == 400 +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd backend && python -m pytest tests/test_ratings.py -v --override-ini="addopts=" +``` + +Expected: FAIL (endpoint doesn't exist yet). + +**Step 3: Implement the endpoint** + +Create `backend/app/api/endpoints/ratings.py`: + +```python +from uuid import UUID +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +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 import User, Session, SessionRating +from app.schemas.analytics import SessionRatingCreate, SessionRatingResponse + +router = APIRouter(tags=["ratings"]) + + +@router.post("/sessions/{session_id}/rate", response_model=SessionRatingResponse, status_code=status.HTTP_201_CREATED) +async def rate_session( + session_id: UUID, + data: SessionRatingCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Submit a CSAT rating (1-5) for a completed session.""" + # Verify session exists and belongs to user + result = await db.execute(select(Session).where(Session.id == session_id)) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + if session.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not your session") + if not session.completed_at: + raise HTTPException(status_code=400, detail="Session not completed yet") + + # Check for duplicate + existing = await db.execute( + select(SessionRating).where(SessionRating.session_id == session_id) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Session already rated") + + rating = SessionRating( + session_id=session_id, + user_id=current_user.id, + tree_id=session.tree_id, + account_id=current_user.account_id, + rating=data.rating, + comment=data.comment, + ) + db.add(rating) + await db.commit() + await db.refresh(rating) + + return SessionRatingResponse( + id=str(rating.id), + session_id=str(rating.session_id), + rating=rating.rating, + comment=rating.comment, + created_at=rating.created_at, + ) +``` + +**Step 4: Register the route** + +In `backend/app/api/router.py`, add: + +```python +from app.api.endpoints import ratings +``` + +And: + +```python +api_router.include_router(ratings.router) +``` + +**Step 5: Run tests to verify they pass** + +```bash +cd backend && python -m pytest tests/test_ratings.py -v --override-ini="addopts=" +``` + +Note: Tests may need fixture adjustments (`completed_session_id`, `active_session_id`). Create appropriate fixtures in `conftest.py` if they don't exist — a completed session is one where `completed_at` is set and `outcome` is populated. + +**Step 6: Commit** + +```bash +git add backend/app/api/endpoints/ratings.py backend/app/api/router.py backend/tests/test_ratings.py +git commit -m "feat: add session CSAT rating endpoint" +``` + +--- + +## Task 4: Step Feedback Endpoint + +**Files:** +- Modify: `backend/app/api/endpoints/ratings.py` +- Add tests to: `backend/tests/test_ratings.py` + +**Step 1: Add step feedback tests** + +Append to `backend/tests/test_ratings.py`: + +```python +async def test_step_feedback_thumbs_up(client: AsyncClient, auth_headers: dict, step_id: str, session_id: str): + """Submit thumbs-up feedback for a step.""" + response = await client.post( + f"/api/v1/steps/{step_id}/feedback", + json={"session_id": session_id, "was_helpful": True}, + headers=auth_headers, + ) + assert response.status_code == 201 + assert response.json()["was_helpful"] is True + + +async def test_step_feedback_toggle(client: AsyncClient, auth_headers: dict, step_id: str, session_id: str): + """Submitting again for same step+session updates the rating.""" + await client.post( + f"/api/v1/steps/{step_id}/feedback", + json={"session_id": session_id, "was_helpful": True}, + headers=auth_headers, + ) + response = await client.post( + f"/api/v1/steps/{step_id}/feedback", + json={"session_id": session_id, "was_helpful": False}, + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["was_helpful"] is False +``` + +**Step 2: Implement the endpoint** + +Add to `backend/app/api/endpoints/ratings.py`: + +```python +from app.models import StepLibrary, StepRating +from app.schemas.analytics import StepFeedbackCreate + + +@router.post("/steps/{step_id}/feedback", status_code=status.HTTP_201_CREATED) +async def submit_step_feedback( + step_id: UUID, + data: StepFeedbackCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Submit thumbs up/down feedback for a step used in a session.""" + # Verify step exists + result = await db.execute(select(StepLibrary).where(StepLibrary.id == step_id)) + step = result.scalar_one_or_none() + if not step: + raise HTTPException(status_code=404, detail="Step not found") + + session_uuid = UUID(data.session_id) + + # Check for existing feedback for this step+user+session + existing_result = await db.execute( + select(StepRating).where( + StepRating.step_id == step_id, + StepRating.user_id == current_user.id, + StepRating.session_id == session_uuid, + ) + ) + existing = existing_result.scalar_one_or_none() + + if existing: + existing.was_helpful = data.was_helpful + status_code = 200 + else: + rating = StepRating( + step_id=step_id, + user_id=current_user.id, + session_id=session_uuid, + was_helpful=data.was_helpful, + ) + db.add(rating) + status_code = 201 + + # Update aggregates on step_library + await _update_step_helpful_counts(db, step_id) + await db.commit() + + return {"step_id": str(step_id), "was_helpful": data.was_helpful, "status": "created" if status_code == 201 else "updated"} + + +async def _update_step_helpful_counts(db: AsyncSession, step_id: UUID): + """Recalculate helpful_yes and helpful_no on step_library.""" + from sqlalchemy import func + yes_count = await db.execute( + select(func.count()).where(StepRating.step_id == step_id, StepRating.was_helpful == True) + ) + no_count = await db.execute( + select(func.count()).where(StepRating.step_id == step_id, StepRating.was_helpful == False) + ) + await db.execute( + StepLibrary.__table__.update() + .where(StepLibrary.id == step_id) + .values(helpful_yes=yes_count.scalar(), helpful_no=no_count.scalar()) + ) +``` + +**Step 3: Run tests** + +```bash +cd backend && python -m pytest tests/test_ratings.py -v --override-ini="addopts=" +``` + +**Step 4: Commit** + +```bash +git add backend/app/api/endpoints/ratings.py backend/tests/test_ratings.py +git commit -m "feat: add step thumbs up/down feedback endpoint" +``` + +--- + +## Task 5: Team Analytics Endpoint + +**Files:** +- Create: `backend/app/api/endpoints/analytics.py` +- Modify: `backend/app/api/router.py` +- Create: `backend/tests/test_analytics.py` + +**Step 1: Write tests** + +Create `backend/tests/test_analytics.py`: + +```python +import pytest +from httpx import AsyncClient + +pytestmark = pytest.mark.asyncio + + +async def test_team_analytics_success(client: AsyncClient, admin_auth_headers: dict): + """Team admin can access team analytics.""" + response = await client.get( + "/api/v1/analytics/team?period=30d", + headers=admin_auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert "summary" in data + assert "time_series" in data + assert "top_flows" in data + assert "top_engineers" in data + assert "total_sessions" in data["summary"] + assert "completion_rate" in data["summary"] + + +async def test_team_analytics_forbidden_for_engineer(client: AsyncClient, auth_headers: dict): + """Regular engineers cannot access team analytics.""" + response = await client.get( + "/api/v1/analytics/team", + headers=auth_headers, + ) + assert response.status_code == 403 + + +async def test_personal_analytics_success(client: AsyncClient, auth_headers: dict): + """Any user can access personal analytics.""" + response = await client.get( + "/api/v1/analytics/me?period=30d", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert "summary" in data + assert "time_series" in data + assert "top_flows" in data + + +async def test_flow_analytics_success(client: AsyncClient, auth_headers: dict, tree_id: str): + """Can access analytics for a visible flow.""" + response = await client.get( + f"/api/v1/analytics/flows/{tree_id}", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert "summary" in data + assert "step_feedback" in data + assert "recent_comments" in data +``` + +**Step 2: Implement analytics endpoint** + +Create `backend/app/api/endpoints/analytics.py`: + +```python +from datetime import datetime, timezone, timedelta +from uuid import UUID +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select, func, case, and_, cast, Date +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 import User, Session, Tree, SessionRating, StepRating +from app.schemas.analytics import ( + TeamAnalyticsResponse, PersonalAnalyticsResponse, FlowAnalyticsResponse, + AnalyticsSummary, OutcomeBreakdown, TimeSeriesPoint, + TopFlow, TopEngineer, StepFeedbackSummary, FlowRatingItem, +) + +router = APIRouter(prefix="/analytics", tags=["analytics"]) + + +def _get_period_start(period: str) -> datetime: + days = {"7d": 7, "30d": 30, "90d": 90}.get(period, 30) + return datetime.now(timezone.utc) - timedelta(days=days) + + +async def _build_summary(db: AsyncSession, base_filter) -> AnalyticsSummary: + """Build analytics summary from a filtered session query.""" + # Total and completed counts + total_q = await db.execute(select(func.count()).select_from(Session).where(*base_filter)) + total = total_q.scalar() or 0 + + completed_q = await db.execute( + select(func.count()).select_from(Session).where(*base_filter, Session.completed_at.isnot(None)) + ) + completed = completed_q.scalar() or 0 + + # Avg duration (minutes) + duration_q = await db.execute( + select( + func.avg(func.extract('epoch', Session.completed_at - Session.started_at) / 60) + ).where(*base_filter, Session.completed_at.isnot(None)) + ) + avg_duration = round(float(duration_q.scalar() or 0), 1) + + # Outcome breakdown + outcome_q = await db.execute( + select(Session.outcome, func.count()).where( + *base_filter, Session.completed_at.isnot(None), Session.outcome.isnot(None) + ).group_by(Session.outcome) + ) + outcomes = dict(outcome_q.all()) + + return AnalyticsSummary( + total_sessions=total, + completed_sessions=completed, + completion_rate=round(completed / total, 3) if total > 0 else 0, + avg_duration_minutes=avg_duration, + outcome_breakdown=OutcomeBreakdown( + resolved=outcomes.get("resolved", 0), + escalated=outcomes.get("escalated", 0), + workaround=outcomes.get("workaround", 0), + unresolved=outcomes.get("unresolved", 0), + ), + ) + + +async def _build_time_series(db: AsyncSession, base_filter, period_start: datetime) -> list[TimeSeriesPoint]: + """Build daily time-series of session counts by outcome.""" + rows = await db.execute( + select( + cast(Session.started_at, Date).label("date"), + func.count().label("sessions"), + func.count().filter(Session.outcome == "resolved").label("resolved"), + func.count().filter(Session.outcome == "escalated").label("escalated"), + func.count().filter(Session.outcome == "workaround").label("workaround"), + func.count().filter(Session.outcome == "unresolved").label("unresolved"), + ).where(*base_filter, Session.started_at >= period_start) + .group_by(cast(Session.started_at, Date)) + .order_by(cast(Session.started_at, Date)) + ) + return [ + TimeSeriesPoint( + date=str(row.date), sessions=row.sessions, + resolved=row.resolved, escalated=row.escalated, + workaround=row.workaround, unresolved=row.unresolved, + ) + for row in rows.all() + ] + + +@router.get("/team", response_model=TeamAnalyticsResponse) +async def get_team_analytics( + period: str = Query("30d", pattern="^(7d|30d|90d)$"), + engineer_id: Optional[UUID] = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Team analytics — team_admin or super_admin only.""" + if not (current_user.is_team_admin or current_user.role == "super_admin"): + raise HTTPException(status_code=403, detail="Team admin access required") + + period_start = _get_period_start(period) + base_filter = [ + Session.started_at >= period_start, + Tree.account_id == current_user.account_id, + ] + if engineer_id: + base_filter.append(Session.user_id == engineer_id) + + # Need to join Session to Tree for account_id scoping + # Adjust queries to use join + summary = await _build_summary_with_join(db, base_filter, period_start) + time_series = await _build_time_series_with_join(db, base_filter, period_start) + + # Top flows + top_flows_q = await db.execute( + select( + Tree.id, Tree.name, + func.count(Session.id).label("sessions"), + (func.count().filter(Session.completed_at.isnot(None)) * 1.0 / func.count()).label("completion_rate"), + func.avg(func.extract('epoch', Session.completed_at - Session.started_at) / 60).label("avg_duration"), + ) + .join(Tree, Session.tree_id == Tree.id) + .where(Session.started_at >= period_start, Tree.account_id == current_user.account_id) + .group_by(Tree.id, Tree.name) + .order_by(func.count(Session.id).desc()) + .limit(10) + ) + top_flows = [ + TopFlow( + tree_id=str(row.id), name=row.name, sessions=row.sessions, + completion_rate=round(float(row.completion_rate or 0), 3), + avg_duration_minutes=round(float(row.avg_duration or 0), 1), + ) + for row in top_flows_q.all() + ] + + # Top engineers + top_engineers_q = await db.execute( + select( + User.id, User.name, + func.count(Session.id).label("sessions"), + (func.count().filter(Session.completed_at.isnot(None)) * 1.0 / func.count()).label("completion_rate"), + func.avg(func.extract('epoch', Session.completed_at - Session.started_at) / 60).label("avg_duration"), + ) + .join(User, Session.user_id == User.id) + .join(Tree, Session.tree_id == Tree.id) + .where(Session.started_at >= period_start, Tree.account_id == current_user.account_id) + .group_by(User.id, User.name) + .order_by(func.count(Session.id).desc()) + .limit(10) + ) + top_engineers = [ + TopEngineer( + user_id=str(row.id), name=row.name or "Unknown", sessions=row.sessions, + completion_rate=round(float(row.completion_rate or 0), 3), + avg_duration_minutes=round(float(row.avg_duration or 0), 1), + ) + for row in top_engineers_q.all() + ] + + return TeamAnalyticsResponse( + summary=summary, time_series=time_series, + top_flows=top_flows, top_engineers=top_engineers, + ) + + +@router.get("/me", response_model=PersonalAnalyticsResponse) +async def get_personal_analytics( + period: str = Query("30d", pattern="^(7d|30d|90d)$"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Personal analytics — any authenticated user.""" + period_start = _get_period_start(period) + base_filter = [Session.started_at >= period_start, Session.user_id == current_user.id] + + summary = await _build_summary(db, base_filter) + time_series = await _build_time_series(db, base_filter, period_start) + + # My top flows + top_flows_q = await db.execute( + select( + Tree.id, Tree.name, + func.count(Session.id).label("sessions"), + (func.count().filter(Session.completed_at.isnot(None)) * 1.0 / func.count()).label("completion_rate"), + func.avg(func.extract('epoch', Session.completed_at - Session.started_at) / 60).label("avg_duration"), + ) + .join(Tree, Session.tree_id == Tree.id) + .where(Session.started_at >= period_start, Session.user_id == current_user.id) + .group_by(Tree.id, Tree.name) + .order_by(func.count(Session.id).desc()) + .limit(10) + ) + top_flows = [ + TopFlow( + tree_id=str(row.id), name=row.name, sessions=row.sessions, + completion_rate=round(float(row.completion_rate or 0), 3), + avg_duration_minutes=round(float(row.avg_duration or 0), 1), + ) + for row in top_flows_q.all() + ] + + return PersonalAnalyticsResponse( + summary=summary, time_series=time_series, top_flows=top_flows, + ) + + +@router.get("/flows/{tree_id}", response_model=FlowAnalyticsResponse) +async def get_flow_analytics( + tree_id: UUID, + period: str = Query("30d", pattern="^(7d|30d|90d)$"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Analytics for a specific flow.""" + # Verify tree exists and user can view it + result = await db.execute(select(Tree).where(Tree.id == tree_id)) + tree = result.scalar_one_or_none() + if not tree: + raise HTTPException(status_code=404, detail="Flow not found") + + period_start = _get_period_start(period) + base_filter = [Session.started_at >= period_start, Session.tree_id == tree_id] + + summary = await _build_summary(db, base_filter) + time_series = await _build_time_series(db, base_filter, period_start) + + # CSAT stats + csat_q = await db.execute( + select(func.avg(SessionRating.rating), func.count()) + .where(SessionRating.tree_id == tree_id, SessionRating.created_at >= period_start) + ) + csat_row = csat_q.one() + avg_csat = round(float(csat_row[0]), 1) if csat_row[0] else None + total_ratings = csat_row[1] + + # Step feedback (thumbs) — aggregate by node_id from decisions JSONB + # This requires joining step_ratings with session decisions + # For v1, return step_library-level feedback if steps are from library + step_feedback: list[StepFeedbackSummary] = [] + + # Recent comments + comments_q = await db.execute( + select(SessionRating.rating, SessionRating.comment, User.name, SessionRating.created_at) + .join(User, SessionRating.user_id == User.id) + .where( + SessionRating.tree_id == tree_id, + SessionRating.comment.isnot(None), + SessionRating.comment != "", + ) + .order_by(SessionRating.created_at.desc()) + .limit(10) + ) + recent_comments = [ + FlowRatingItem( + rating=row.rating, comment=row.comment, + user_name=row.name, created_at=row.created_at, + ) + for row in comments_q.all() + ] + + return FlowAnalyticsResponse( + summary=summary, avg_csat=avg_csat, total_ratings=total_ratings, + time_series=time_series, step_feedback=step_feedback, + recent_comments=recent_comments, + ) +``` + +Note: The `_build_summary_with_join` and `_build_time_series_with_join` helper functions need to be implemented with proper Session-Tree joins for account scoping. The implementing engineer should refactor `_build_summary` and `_build_time_series` to optionally accept a join condition. + +**Step 3: Register route** + +In `backend/app/api/router.py`: + +```python +from app.api.endpoints import analytics +api_router.include_router(analytics.router) +``` + +**Step 4: Run tests** + +```bash +cd backend && python -m pytest tests/test_analytics.py -v --override-ini="addopts=" +``` + +**Step 5: Commit** + +```bash +git add backend/app/api/endpoints/analytics.py backend/app/api/router.py backend/tests/test_analytics.py +git commit -m "feat: add team, personal, and flow analytics endpoints" +``` + +--- + +## Task 6: Frontend Setup — Recharts, Types, API Client + +**Files:** +- Modify: `frontend/package.json` (install recharts) +- Create: `frontend/src/types/analytics.ts` +- Modify: `frontend/src/types/index.ts` +- Create: `frontend/src/api/analytics.ts` +- Modify: `frontend/src/api/index.ts` +- Modify: `frontend/src/api/sessions.ts` + +**Step 1: Install Recharts** + +```bash +cd frontend && npm install recharts +``` + +**Step 2: Create analytics types** + +Create `frontend/src/types/analytics.ts`: + +```typescript +export interface OutcomeBreakdown { + resolved: number + escalated: number + workaround: number + unresolved: number +} + +export interface AnalyticsSummary { + total_sessions: number + completed_sessions: number + completion_rate: number + avg_duration_minutes: number + outcome_breakdown: OutcomeBreakdown +} + +export interface TimeSeriesPoint { + date: string + sessions: number + resolved: number + escalated: number + workaround: number + unresolved: number +} + +export interface TopFlow { + tree_id: string + name: string + sessions: number + completion_rate: number + avg_duration_minutes: number + avg_csat?: number +} + +export interface TopEngineer { + user_id: string + name: string + sessions: number + completion_rate: number + avg_duration_minutes: number +} + +export interface TeamAnalyticsResponse { + summary: AnalyticsSummary + time_series: TimeSeriesPoint[] + top_flows: TopFlow[] + top_engineers: TopEngineer[] +} + +export interface PersonalAnalyticsResponse { + summary: AnalyticsSummary + time_series: TimeSeriesPoint[] + top_flows: TopFlow[] +} + +export interface StepFeedbackSummary { + node_id: string + node_title: string + helpful_yes: number + helpful_no: number + helpful_rate: number +} + +export interface FlowRatingItem { + rating: number + comment?: string + user_name?: string + created_at: string +} + +export interface FlowAnalyticsResponse { + summary: AnalyticsSummary + avg_csat?: number + total_ratings: number + time_series: TimeSeriesPoint[] + step_feedback: StepFeedbackSummary[] + recent_comments: FlowRatingItem[] +} + +export type AnalyticsPeriod = '7d' | '30d' | '90d' +``` + +**Step 3: Export from types/index.ts** + +Add to `frontend/src/types/index.ts`: + +```typescript +export type * from './analytics' +``` + +**Step 4: Create analytics API client** + +Create `frontend/src/api/analytics.ts`: + +```typescript +import { apiClient } from './client' +import type { + TeamAnalyticsResponse, + PersonalAnalyticsResponse, + FlowAnalyticsResponse, + AnalyticsPeriod, +} from '@/types' + +export const analyticsApi = { + async getTeamAnalytics(period: AnalyticsPeriod = '30d', engineerId?: string): Promise { + const params: Record = { period } + if (engineerId) params.engineer_id = engineerId + const response = await apiClient.get('/analytics/team', { params }) + return response.data + }, + + async getPersonalAnalytics(period: AnalyticsPeriod = '30d'): Promise { + const response = await apiClient.get('/analytics/me', { params: { period } }) + return response.data + }, + + async getFlowAnalytics(treeId: string, period: AnalyticsPeriod = '30d'): Promise { + const response = await apiClient.get(`/analytics/flows/${treeId}`, { params: { period } }) + return response.data + }, + + async rateSession(sessionId: string, rating: number, comment?: string): Promise { + await apiClient.post(`/sessions/${sessionId}/rate`, { rating, comment }) + }, + + async submitStepFeedback(stepId: string, sessionId: string, wasHelpful: boolean): Promise { + await apiClient.post(`/steps/${stepId}/feedback`, { session_id: sessionId, was_helpful: wasHelpful }) + }, +} +``` + +**Step 5: Export from api/index.ts** + +Add to `frontend/src/api/index.ts`: + +```typescript +export { analyticsApi } from './analytics' +``` + +**Step 6: Verify build** + +```bash +cd frontend && npm run build +``` + +**Step 7: Commit** + +```bash +git add frontend/package.json frontend/package-lock.json frontend/src/types/analytics.ts frontend/src/types/index.ts frontend/src/api/analytics.ts frontend/src/api/index.ts +git commit -m "feat: add recharts, analytics types, and API client" +``` + +--- + +## Task 7: Inline Step Feedback Component + +**Files:** +- Create: `frontend/src/components/session/StepFeedback.tsx` +- Modify: `frontend/src/pages/TreeNavigationPage.tsx` +- Modify: `frontend/src/pages/ProceduralNavigationPage.tsx` (same pattern) + +**Step 1: Create StepFeedback component** + +Create `frontend/src/components/session/StepFeedback.tsx`: + +```tsx +import { useState, useEffect } from 'react' +import { ThumbsUp, ThumbsDown } from 'lucide-react' +import { analyticsApi } from '@/api' +import { cn } from '@/lib/utils' + +interface StepFeedbackProps { + stepId: string + sessionId: string +} + +const HINT_KEY = 'rf-step-feedback-hint-dismissed' + +export function StepFeedback({ stepId, sessionId }: StepFeedbackProps) { + const [feedback, setFeedback] = useState(null) + const [submitting, setSubmitting] = useState(false) + const [showHint, setShowHint] = useState(false) + + useEffect(() => { + if (!localStorage.getItem(HINT_KEY)) { + setShowHint(true) + } + }, []) + + const handleFeedback = async (wasHelpful: boolean) => { + if (submitting) return + setSubmitting(true) + try { + const newValue = feedback === wasHelpful ? null : wasHelpful + if (newValue !== null) { + await analyticsApi.submitStepFeedback(stepId, sessionId, newValue) + } + setFeedback(newValue) + if (showHint) { + setShowHint(false) + localStorage.setItem(HINT_KEY, '1') + } + } catch { + // Silently fail — feedback is non-critical + } finally { + setSubmitting(false) + } + } + + return ( +
+ {showHint && ( + Rate this step to help improve flows + )} + + +
+ ) +} +``` + +**Step 2: Integrate into TreeNavigationPage** + +In `TreeNavigationPage.tsx`, find where step content is rendered (after the step description/help_text, before decision options or continue button). Add: + +```tsx +import { StepFeedback } from '@/components/session/StepFeedback' + +// Inside the step rendering, after content and before options: +{currentNode && session && ( +
+ +
+)} +``` + +Apply the same pattern to `ProceduralNavigationPage.tsx` for procedural flow steps. + +**Step 3: Verify build** + +```bash +cd frontend && npm run build +``` + +**Step 4: Commit** + +```bash +git add frontend/src/components/session/StepFeedback.tsx frontend/src/pages/TreeNavigationPage.tsx frontend/src/pages/ProceduralNavigationPage.tsx +git commit -m "feat: add inline step thumbs up/down feedback during sessions" +``` + +--- + +## Task 8: CSAT Modal Component + +**Files:** +- Create: `frontend/src/components/session/CSATModal.tsx` +- Modify: `frontend/src/pages/TreeNavigationPage.tsx` +- Modify: `frontend/src/pages/ProceduralNavigationPage.tsx` + +**Step 1: Create CSATModal** + +Create `frontend/src/components/session/CSATModal.tsx`: + +```tsx +import { useState } from 'react' +import { Star } from 'lucide-react' +import { Modal } from '@/components/common/Modal' +import { analyticsApi } from '@/api' +import { cn } from '@/lib/utils' + +interface CSATModalProps { + isOpen: boolean + onClose: () => void + sessionId: string +} + +const RATED_SESSIONS_KEY = 'rf-rated-sessions' + +function getRatedSessions(): string[] { + try { + return JSON.parse(localStorage.getItem(RATED_SESSIONS_KEY) || '[]') + } catch { + return [] + } +} + +function markSessionRated(sessionId: string) { + const rated = getRatedSessions() + rated.push(sessionId) + localStorage.setItem(RATED_SESSIONS_KEY, JSON.stringify(rated.slice(-100))) +} + +export function hasBeenRated(sessionId: string): boolean { + return getRatedSessions().includes(sessionId) +} + +export function CSATModal({ isOpen, onClose, sessionId }: CSATModalProps) { + const [rating, setRating] = useState(0) + const [hoveredRating, setHoveredRating] = useState(0) + const [comment, setComment] = useState('') + const [submitting, setSubmitting] = useState(false) + + const handleSubmit = async () => { + if (rating === 0 || submitting) return + setSubmitting(true) + try { + await analyticsApi.rateSession(sessionId, rating, comment || undefined) + markSessionRated(sessionId) + onClose() + } catch { + // Silently fail + onClose() + } finally { + setSubmitting(false) + } + } + + const handleSkip = () => { + markSessionRated(sessionId) + onClose() + } + + return ( + +
+

+ Your feedback helps flow authors improve troubleshooting paths. +

+ + {/* Star rating */} +
+ {[1, 2, 3, 4, 5].map((value) => ( + + ))} +
+ + {/* Comment */} +