# Admin Survey Responses Page — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build an admin page to view submitted survey responses with expandable row detail and CSV export. **Architecture:** New backend endpoints on the existing `admin_survey.py` router + new frontend page following the `SurveyInvitesPage` pattern. Expandable rows show Q&A detail inline. CSV export endpoint returns a downloadable file. **Tech Stack:** FastAPI, SQLAlchemy async, Pydantic v2, React, Tailwind CSS, Lucide icons --- ### Task 1: Backend — Survey response schemas **Files:** - Modify: `backend/app/schemas/survey.py` **Step 1: Add response schemas to survey.py** Add these schemas at the end of `backend/app/schemas/survey.py`: ```python class SurveyResponseDetail(BaseModel): """Full survey response returned to admin.""" id: str respondent_name: Optional[str] responses: dict[str, Any] source: str # "invite" or "direct" invite_name: Optional[str] created_at: datetime class SurveyResponseListResponse(BaseModel): """List of survey responses with summary stats.""" responses: list[SurveyResponseDetail] total: int this_week: int ``` **Step 2: Commit** ```bash git add backend/app/schemas/survey.py git commit -m "feat: add survey response admin schemas" ``` --- ### Task 2: Backend — List survey responses endpoint **Files:** - Modify: `backend/app/api/endpoints/admin_survey.py` **Step 1: Write the failing test** Add to `backend/tests/test_survey.py`: ```python @pytest.mark.asyncio async def test_list_survey_responses_admin(client, admin_auth_headers): """Admin can list survey responses.""" # Submit a response first await client.post( "/api/v1/survey/submit", json={"respondent_name": "Tester", "responses": {"q1": "answer"}}, ) res = await client.get("/api/v1/admin/survey-responses", headers=admin_auth_headers) assert res.status_code == 200 data = res.json() assert "responses" in data assert "total" in data assert "this_week" in data assert data["total"] >= 1 assert data["responses"][0]["respondent_name"] == "Tester" assert data["responses"][0]["source"] == "direct" @pytest.mark.asyncio async def test_list_survey_responses_requires_admin(client, auth_headers): """Non-admin cannot list survey responses.""" res = await client.get("/api/v1/admin/survey-responses", headers=auth_headers) assert res.status_code == 403 ``` **Step 2: Run tests to verify they fail** Run: `cd backend && python -m pytest tests/test_survey.py::test_list_survey_responses_admin tests/test_survey.py::test_list_survey_responses_requires_admin -v` Expected: FAIL (404 — endpoint doesn't exist yet) **Step 3: Implement the endpoint** Add to `backend/app/api/endpoints/admin_survey.py`: ```python from datetime import datetime, timezone, timedelta from app.models.survey_response import SurveyResponse from app.schemas.survey import SurveyResponseDetail, SurveyResponseListResponse @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.""" result = await db.execute( select(SurveyResponse, SurveyInvite.recipient_name.label("invite_name")) .outerjoin(SurveyInvite, SurveyResponse.invite_id == SurveyInvite.id) .order_by(SurveyResponse.created_at.desc()) ) rows = result.all() one_week_ago = datetime.now(timezone.utc) - timedelta(days=7) responses = [] this_week = 0 for row in rows: sr = row[0] # SurveyResponse inv_name = row[1] # invite_name or None detail = SurveyResponseDetail( id=str(sr.id), respondent_name=sr.respondent_name, responses=sr.responses, source="invite" if sr.invite_id else "direct", invite_name=inv_name, created_at=sr.created_at, ) responses.append(detail) if sr.created_at >= one_week_ago: this_week += 1 return SurveyResponseListResponse( responses=responses, total=len(responses), this_week=this_week, ) ``` Add the missing imports at the top of `admin_survey.py`: ```python from datetime import datetime, timezone, timedelta from app.models.survey_response import SurveyResponse from app.schemas.survey import SurveyResponseDetail, SurveyResponseListResponse ``` **Step 4: Run tests to verify they pass** Run: `cd backend && python -m pytest tests/test_survey.py -v` Expected: ALL PASS **Step 5: Commit** ```bash git add backend/app/api/endpoints/admin_survey.py backend/tests/test_survey.py git commit -m "feat: add admin list survey responses endpoint" ``` --- ### Task 3: Backend — CSV export endpoint **Files:** - Modify: `backend/app/api/endpoints/admin_survey.py` **Step 1: Write the failing test** Add to `backend/tests/test_survey.py`: ```python @pytest.mark.asyncio async def test_export_survey_responses_csv(client, admin_auth_headers): """Admin can export survey responses as CSV.""" # Submit a response await client.post( "/api/v1/survey/submit", json={ "respondent_name": "CSV Tester", "responses": { "prereqs": ["Who's affected", "What changed recently"], "verify_fix": "Have the user confirm", "steps_at_a_time": "5 steps", "prioritization": ["Likelihood", "Speed", "Blast radius"], }, }, ) res = await client.get("/api/v1/admin/survey-responses/export", headers=admin_auth_headers) assert res.status_code == 200 assert "text/csv" in res.headers["content-type"] assert "attachment" in res.headers.get("content-disposition", "") body = res.text assert "CSV Tester" in body assert "Respondent" in body # Header row @pytest.mark.asyncio async def test_export_survey_responses_requires_admin(client, auth_headers): """Non-admin cannot export survey responses.""" res = await client.get("/api/v1/admin/survey-responses/export", headers=auth_headers) assert res.status_code == 403 ``` **Step 2: Run tests to verify they fail** Run: `cd backend && python -m pytest tests/test_survey.py::test_export_survey_responses_csv tests/test_survey.py::test_export_survey_responses_requires_admin -v` Expected: FAIL (404) **Step 3: Implement the CSV export endpoint** Add to `backend/app/api/endpoints/admin_survey.py`: ```python import csv import io from fastapi.responses import StreamingResponse # Question IDs in survey order, with display labels SURVEY_QUESTIONS = [ ("prereqs", "Q1: Pre-work info needed"), ("verify_fix", "Q2: How you verify a fix"), ("steps_at_a_time", "Q3: Steps at a time preference"), ("first_step", "Q4: First move on vague ticket"), ("junior_mistake", "Q5: Common junior mistake"), ("pivot", "Q6: When to pivot theories"), ("scenario_approach", "Q7: Scenario diagnostic steps"), ("scenario_deeper", "Q8: Scenario server checks"), ("doc_pct", "Q9: Documentation percentage"), ("go_to_commands", "Q10: Go-to commands"), ("secret_weapon", "Q11: Secret weapon"), ("gotcha", "Q12: Wrong obvious diagnosis"), ("hard_rules", "Q13: Hard rules followed"), ("prioritization", "Q14: Diagnostic prioritization"), ("detail_level", "Q15: AI suggestion specificity"), ("ai_personality", "Q16: AI colleague traits"), ] def _format_answer(value) -> str: """Format a survey answer for CSV output.""" if value is None: return "" if isinstance(value, list): # Ranked items: numbered; multi-select: semicolon-joined if len(value) > 0 and all(isinstance(v, str) for v in value): return "; ".join(f"{i+1}. {v}" if len(value) > 3 else v for i, v in enumerate(value)) return "; ".join(str(v) for v in value) return str(value) @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 a CSV file.""" result = await db.execute( select(SurveyResponse).order_by(SurveyResponse.created_at.desc()) ) responses = result.scalars().all() output = io.StringIO() writer = csv.writer(output) # Header row headers = ["Respondent", "Date"] + [label for _, label in SURVEY_QUESTIONS] writer.writerow(headers) # Data rows for sr in responses: row = [ sr.respondent_name or "Anonymous", sr.created_at.strftime("%Y-%m-%d %H:%M") if sr.created_at else "", ] for qid, _ in SURVEY_QUESTIONS: row.append(_format_answer(sr.responses.get(qid))) writer.writerow(row) output.seek(0) return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=survey-responses.csv"}, ) ``` **Step 4: Run tests to verify they pass** Run: `cd backend && python -m pytest tests/test_survey.py -v` Expected: ALL PASS **Step 5: Commit** ```bash git add backend/app/api/endpoints/admin_survey.py backend/tests/test_survey.py git commit -m "feat: add admin CSV export for survey responses" ``` --- ### Task 4: Frontend — Admin API client functions **Files:** - Modify: `frontend/src/api/admin.ts` **Step 1: Add the response type and API functions** Add the type after the existing `SurveyInviteResponse` interface in `frontend/src/api/admin.ts`: ```typescript export interface SurveyResponseDetail { id: string respondent_name: string | null responses: Record source: 'invite' | 'direct' invite_name: string | null created_at: string } export interface SurveyResponseListResponse { responses: SurveyResponseDetail[] total: number this_week: number } ``` Add these functions inside the `adminApi` object, after the `createSurveyInvite` entry: ```typescript // Survey Responses listSurveyResponses: () => api.get('/admin/survey-responses').then(r => r.data), exportSurveyResponsesCsv: () => api.get('/admin/survey-responses/export', { responseType: 'blob' }).then(r => r.data), ``` **Step 2: Commit** ```bash git add frontend/src/api/admin.ts git commit -m "feat: add survey responses admin API client" ``` --- ### Task 5: Frontend — Survey Responses admin page **Files:** - Create: `frontend/src/pages/admin/SurveyResponsesPage.tsx` **Step 1: Create the page** Create `frontend/src/pages/admin/SurveyResponsesPage.tsx`: ```tsx import { useState, useEffect } from 'react' import { adminApi } from '@/api/admin' import type { SurveyResponseDetail } from '@/api/admin' import { PageHeader } from '@/components/admin' import { ChevronDown, Download, User, Link2, Loader2 } from 'lucide-react' import { cn } from '@/lib/utils' // Question metadata for display const QUESTIONS: { id: string; num: string; text: string; type: 'mc' | 'mc-multi' | 'range' | 'text' | 'rank' }[] = [ { id: 'prereqs', num: '1', text: 'Before you start troubleshooting, what info do you need?', type: 'mc-multi' }, { id: 'verify_fix', num: '2', text: 'After you apply a fix, how do you verify it actually worked?', type: 'mc' }, { id: 'steps_at_a_time', num: '3', text: 'How many steps do you prefer to see at once?', type: 'range' }, { id: 'first_step', num: '4', text: 'A vague ticket comes in: "Internet is down." What\'s your FIRST move?', type: 'mc' }, { id: 'junior_mistake', num: '5', text: 'Most common mistake you see junior engineers make?', type: 'mc' }, { id: 'pivot', num: '6', text: 'How do you decide when to stop pursuing one theory and pivot?', type: 'mc' }, { id: 'scenario_approach', num: '7', text: 'Walk through your first 3 diagnostic steps for this ticket.', type: 'text' }, { id: 'scenario_deeper', num: '8', text: 'Server pings fine, you can RDP in. What do you check next?', type: 'text' }, { id: 'doc_pct', num: '9', text: 'What percentage of troubleshooting steps do you actually document?', type: 'range' }, { id: 'go_to_commands', num: '10', text: 'Top 3 go-to PowerShell commands or one-liners?', type: 'text' }, { id: 'secret_weapon', num: '11', text: 'Secret weapon command/tool/technique?', type: 'text' }, { id: 'gotcha', num: '12', text: 'Issue where the obvious diagnosis was WRONG?', type: 'text' }, { id: 'hard_rules', num: '13', text: 'Which "rules" do you follow?', type: 'mc-multi' }, { id: 'prioritization', num: '14', text: 'Rank factors by diagnostic priority influence.', type: 'rank' }, { id: 'detail_level', num: '15', text: 'How specific should AI diagnostic suggestions be?', type: 'mc' }, { id: 'ai_personality', num: '16', text: 'What makes an AI feel like a useful colleague?', type: 'mc' }, ] export default function SurveyResponsesPage() { const [responses, setResponses] = useState([]) const [total, setTotal] = useState(0) const [thisWeek, setThisWeek] = useState(0) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [expandedId, setExpandedId] = useState(null) const [exporting, setExporting] = useState(false) useEffect(() => { const load = async () => { try { const data = await adminApi.listSurveyResponses() setResponses(data.responses) setTotal(data.total) setThisWeek(data.this_week) } catch { setError('Failed to load survey responses') } finally { setLoading(false) } } load() }, []) const handleExport = async () => { setExporting(true) try { const blob = await adminApi.exportSurveyResponsesCsv() const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = 'survey-responses.csv' a.click() URL.revokeObjectURL(url) } catch { setError('Export failed') } finally { setExporting(false) } } const formatDate = (dateStr: string) => new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', }) return (
{/* Stats */}
Total Responses
{total}
This Week
{thisWeek}
{error &&

{error}

} {/* Responses Table */}
{loading ? ( ) : responses.length === 0 ? ( ) : ( responses.map((r, idx) => { const isExpanded = expandedId === r.id const answeredCount = Object.keys(r.responses).filter(k => { const v = r.responses[k] return v !== undefined && v !== '' && (!Array.isArray(v) || v.length > 0) }).length return ( setExpandedId(isExpanded ? null : r.id)} formatDate={formatDate} /> ) }) )}
# Respondent Source Date Answered
Loading...
No responses yet
) } function ResponseRow({ response: r, index, answeredCount, isExpanded, onToggle, formatDate, }: { response: SurveyResponseDetail index: number answeredCount: number isExpanded: boolean onToggle: () => void formatDate: (d: string) => string }) { return ( <> {index} {r.respondent_name || 'Anonymous'} {r.source === 'invite' ? : } {r.source === 'invite' ? r.invite_name || 'Invite' : 'Direct'} {formatDate(r.created_at)} {answeredCount} / {QUESTIONS.length} {isExpanded && ( )} ) } function ExpandedDetail({ responses }: { responses: Record }) { return (
{QUESTIONS.map(q => { const answer = responses[q.id] const hasAnswer = answer !== undefined && answer !== '' && (!Array.isArray(answer) || answer.length > 0) return (
Q{q.num}
{q.text}
{!hasAnswer ? (
No answer
) : q.type === 'mc-multi' && Array.isArray(answer) ? (
{answer.map((v, i) => ( {v} ))}
) : q.type === 'rank' && Array.isArray(answer) ? (
    {answer.map((v, i) => (
  1. {i + 1} {v}
  2. ))}
) : q.type === 'text' ? (
{String(answer)}
) : (
{String(answer)}
)}
) })}
) } ``` **Step 2: Verify build** Run: `cd frontend && npm run build` Expected: Build succeeds (page isn't routed yet but should compile) **Step 3: Commit** ```bash git add frontend/src/pages/admin/SurveyResponsesPage.tsx git commit -m "feat: add admin survey responses page component" ``` --- ### Task 6: Frontend — Wire up route, sidebar, and lazy import **Files:** - Modify: `frontend/src/router.tsx` - Modify: `frontend/src/components/admin/AdminSidebar.tsx` **Step 1: Add lazy import to router.tsx** Add after the `AdminSurveyInvitesPage` lazy import (line 54): ```typescript const AdminSurveyResponsesPage = lazy(() => import('@/pages/admin/SurveyResponsesPage')) ``` **Step 2: Add route to router.tsx** Add a new route object after the `survey-invites` route (after line 405): ```typescript { path: 'survey-responses', element: ( }> ), }, ``` **Step 3: Add sidebar nav item** In `frontend/src/components/admin/AdminSidebar.tsx`: Add `MessageSquareText` to the lucide import: ```typescript import { LayoutDashboard, Users, Ticket, FileText, Gauge, ToggleLeft, Settings, FolderTree, ClipboardList, MessageSquareText, ArrowLeft, } from 'lucide-react' ``` Add the nav item after the Survey Invites entry: ```typescript { path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText }, ``` **Step 4: Verify build** Run: `cd frontend && npm run build` Expected: Build succeeds with no errors **Step 5: Commit** ```bash git add frontend/src/router.tsx frontend/src/components/admin/AdminSidebar.tsx git commit -m "feat: wire up admin survey responses route and sidebar nav" ``` --- ### Task 7: End-to-end verification **Step 1: Run all backend tests** Run: `cd backend && python -m pytest tests/test_survey.py -v` Expected: ALL PASS **Step 2: Run frontend build** Run: `cd frontend && npm run build` Expected: Build succeeds **Step 3: Manual verification** 1. Start backend: `cd backend && uvicorn app.main:app --reload` 2. Start frontend: `cd frontend && npm run dev` 3. Submit a test survey at `http://localhost:5173/survey` 4. Log in as admin, navigate to Admin > Survey Responses 5. Verify: stats show, table shows response, click to expand, verify Q&A renders 6. Click Export CSV, verify file downloads with correct data **Step 4: Final commit** ```bash git add -A git commit -m "feat: admin survey responses page with expandable detail and CSV export - Backend: GET /admin/survey-responses (list with stats) - Backend: GET /admin/survey-responses/export (CSV download) - Frontend: SurveyResponsesPage with expandable row detail - Two-column Q&A grid with typed answer rendering - Stats cards (total, this week) - CSV export button Co-Authored-By: Claude Opus 4.6 " ```