Files
resolutionflow/backend/app/api/endpoints/admin_survey.py
chihlasm 199cf315c6 feat: admin survey responses page with expandable detail and CSV export
- Backend: GET /admin/survey-responses (list with stats, invite join)
- Backend: GET /admin/survey-responses/export (CSV download)
- Frontend: SurveyResponsesPage with expandable row detail
- Two-column Q&A grid with typed answer rendering (chips, ranked lists, quote blocks)
- Stats cards (total responses, this week)
- CSV export button with blob download
- Sidebar nav + route wiring
- Also: updated Q14 from product domain ranking to diagnostic prioritization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:55:49 -05:00

238 lines
7.4 KiB
Python

"""Admin endpoints for managing survey invites and responses."""
import csv
import io
import logging
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_admin
from app.core.config import settings
from app.core.database import get_db
from app.core.email import EmailService
from app.models.survey_invite import SurveyInvite
from app.models.survey_response import SurveyResponse
from app.models.user import User
from app.schemas.survey import (
SurveyInviteCreate,
SurveyInviteResponse,
SurveyResponseDetail,
SurveyResponseListResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin", tags=["admin-survey"])
FRONTEND_URL = "https://resolutionflow.com"
def _build_invite_response(invite: SurveyInvite) -> SurveyInviteResponse:
base_url = FRONTEND_URL if not settings.DEBUG else "http://localhost:5173"
return SurveyInviteResponse(
id=str(invite.id),
token=invite.token,
recipient_name=invite.recipient_name,
recipient_email=invite.recipient_email,
status=invite.status,
email_sent=invite.email_sent,
created_at=invite.created_at,
completed_at=invite.completed_at,
survey_url=f"{base_url}/survey?t={invite.token}",
)
@router.post("/survey-invites", response_model=SurveyInviteResponse)
async def create_survey_invite(
data: SurveyInviteCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a survey invite. Optionally sends email."""
invite = SurveyInvite(
recipient_name=data.recipient_name,
recipient_email=data.recipient_email,
)
db.add(invite)
await db.flush()
if data.send_email and data.recipient_email:
try:
base_url = FRONTEND_URL if not settings.DEBUG else "http://localhost:5173"
survey_url = f"{base_url}/survey?t={invite.token}"
sent = await EmailService.send_survey_invite_email(
to_email=data.recipient_email,
recipient_name=data.recipient_name,
survey_url=survey_url,
)
if sent:
invite.email_sent = True
except Exception:
logger.exception("Failed to send survey invite email")
await db.commit()
await db.refresh(invite)
return _build_invite_response(invite)
@router.get("/survey-invites", response_model=list[SurveyInviteResponse])
async def list_survey_invites(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all survey invites."""
result = await db.execute(
select(SurveyInvite).order_by(SurveyInvite.created_at.desc())
)
invites = result.scalars().all()
return [_build_invite_response(i) for i in invites]
@router.get("/survey-responses", response_model=SurveyResponseListResponse)
async def list_survey_responses(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all survey responses with summary stats."""
stmt = (
select(SurveyResponse, SurveyInvite.recipient_name)
.outerjoin(SurveyInvite, SurveyResponse.invite_id == SurveyInvite.id)
.order_by(SurveyResponse.created_at.desc())
)
result = await db.execute(stmt)
rows = result.all()
one_week_ago = datetime.now(timezone.utc) - timedelta(days=7)
this_week = 0
responses: list[SurveyResponseDetail] = []
for survey_resp, invite_name in rows:
source = "invite" if survey_resp.invite_id else "direct"
responses.append(
SurveyResponseDetail(
id=str(survey_resp.id),
respondent_name=survey_resp.respondent_name,
responses=survey_resp.responses,
source=source,
invite_name=invite_name,
created_at=survey_resp.created_at,
)
)
if survey_resp.created_at >= one_week_ago:
this_week += 1
return SurveyResponseListResponse(
responses=responses,
total=len(responses),
this_week=this_week,
)
# Question IDs in survey order, used for CSV export columns.
QUESTION_IDS = [
"prereqs",
"verify_fix",
"steps_at_a_time",
"first_step",
"junior_mistake",
"pivot",
"scenario_approach",
"scenario_deeper",
"doc_pct",
"go_to_commands",
"secret_weapon",
"gotcha",
"hard_rules",
"prioritization",
"detail_level",
"ai_personality",
]
QUESTION_LABELS = {
"prereqs": "Q1: Prereqs",
"verify_fix": "Q2: Verify Fix",
"steps_at_a_time": "Q3: Steps at a Time",
"first_step": "Q4: First Step",
"junior_mistake": "Q5: Junior Mistake",
"pivot": "Q6: Pivot",
"scenario_approach": "Q7: Scenario Approach",
"scenario_deeper": "Q8: Scenario Deeper",
"doc_pct": "Q9: Doc Pct",
"go_to_commands": "Q10: Go-To Commands",
"secret_weapon": "Q11: Secret Weapon",
"gotcha": "Q12: Gotcha",
"hard_rules": "Q13: Hard Rules",
"prioritization": "Q14: Prioritization",
"detail_level": "Q15: Detail Level",
"ai_personality": "Q16: AI Personality",
}
def _format_answer(value: Any) -> str:
"""Format a survey answer for CSV export."""
if value is None:
return ""
if isinstance(value, list):
# Check if this looks like a ranked list (list of strings to number)
# Rank answers: "1. X; 2. Y"
# Multi-select: "X; Y"
# Heuristic: if it's a list, treat as multi-select joined with "; "
return "; ".join(str(v) for v in value)
return str(value)
def _format_ranked_answer(value: Any) -> str:
"""Format a ranked answer: '1. X; 2. Y'."""
if isinstance(value, list):
return "; ".join(f"{i + 1}. {v}" for i, v in enumerate(value))
return _format_answer(value)
# Question IDs that use ranked answers
RANKED_QUESTIONS = {"prioritization"}
@router.get("/survey-responses/export")
async def export_survey_responses_csv(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Export all survey responses as CSV."""
stmt = (
select(SurveyResponse)
.order_by(SurveyResponse.created_at.desc())
)
result = await db.execute(stmt)
rows = result.scalars().all()
output = io.StringIO()
writer = csv.writer(output)
# Header row
headers = ["Respondent", "Date"] + [QUESTION_LABELS.get(qid, qid) for qid in QUESTION_IDS]
writer.writerow(headers)
for resp in rows:
row = [
resp.respondent_name or "Anonymous",
resp.created_at.strftime("%Y-%m-%d %H:%M") if resp.created_at else "",
]
for qid in QUESTION_IDS:
answer = resp.responses.get(qid) if resp.responses else None
if qid in RANKED_QUESTIONS:
row.append(_format_ranked_answer(answer))
else:
row.append(_format_answer(answer))
writer.writerow(row)
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=survey-responses.csv"},
)