diff --git a/backend/app/api/endpoints/ratings.py b/backend/app/api/endpoints/ratings.py new file mode 100644 index 00000000..e192b637 --- /dev/null +++ b/backend/app/api/endpoints/ratings.py @@ -0,0 +1,57 @@ +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, + ) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 5537529f..de0cffc5 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 api_router = APIRouter() @@ -25,3 +26,4 @@ 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) diff --git a/backend/tests/test_ratings.py b/backend/tests/test_ratings.py new file mode 100644 index 00000000..d623bd0d --- /dev/null +++ b/backend/tests/test_ratings.py @@ -0,0 +1,89 @@ +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