From 8e0ad7498679a237db2d25adb4fdd502a345b4f7 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sun, 15 Feb 2026 23:50:47 -0500 Subject: [PATCH] docs: add analytics & feedback implementation plan 12-task TDD plan covering session ratings, step feedback, team/personal/flow analytics endpoints, and frontend pages. Co-Authored-By: Claude Opus 4.6 --- ...02-15-analytics-feedback-implementation.md | 1626 +++++++++++++++++ 1 file changed, 1626 insertions(+) create mode 100644 docs/plans/2026-02-15-analytics-feedback-implementation.md 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 */} +