diff --git a/backend/app/api/endpoints/admin_survey.py b/backend/app/api/endpoints/admin_survey.py index edc91a54..a107fd02 100644 --- a/backend/app/api/endpoints/admin_survey.py +++ b/backend/app/api/endpoints/admin_survey.py @@ -1,8 +1,12 @@ -"""Admin endpoints for managing survey invites.""" +"""Admin endpoints for managing survey invites and responses.""" +import csv +import io import logging -from typing import Annotated +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 @@ -11,8 +15,14 @@ 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 +from app.schemas.survey import ( + SurveyInviteCreate, + SurveyInviteResponse, + SurveyResponseDetail, + SurveyResponseListResponse, +) logger = logging.getLogger(__name__) @@ -80,3 +90,148 @@ async def list_survey_invites( ) 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"}, + ) diff --git a/backend/app/schemas/survey.py b/backend/app/schemas/survey.py index 0aac7fac..dbf3041e 100644 --- a/backend/app/schemas/survey.py +++ b/backend/app/schemas/survey.py @@ -44,3 +44,20 @@ class SurveyInviteStatus(BaseModel): """Public invite status check — minimal info.""" name: str status: str + + +class SurveyResponseDetail(BaseModel): + """Full survey response returned to admin.""" + id: str + respondent_name: Optional[str] + responses: dict[str, Any] + source: str + 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 diff --git a/backend/tests/test_survey.py b/backend/tests/test_survey.py index ebde15c9..8b175b38 100644 --- a/backend/tests/test_survey.py +++ b/backend/tests/test_survey.py @@ -113,3 +113,59 @@ async def test_create_and_list_invites(client, admin_auth_headers): assert list_res.status_code == 200 invites = list_res.json() assert len(invites) >= 1 + + +@pytest.mark.asyncio +async def test_list_survey_responses_admin(client, admin_auth_headers): + """Admin can list survey responses.""" + 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 + + +@pytest.mark.asyncio +async def test_export_survey_responses_csv(client, admin_auth_headers): + """Admin can export survey responses as CSV.""" + 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 + + +@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 diff --git a/docs/plans/2026-03-05-admin-survey-responses.md b/docs/plans/2026-03-05-admin-survey-responses.md new file mode 100644 index 00000000..b4aca254 --- /dev/null +++ b/docs/plans/2026-03-05-admin-survey-responses.md @@ -0,0 +1,747 @@ +# 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} + /> + ) + }) + )} + +
+ #RespondentSourceDateAnswered
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 " +``` diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index e6180756..be915ebc 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -32,6 +32,21 @@ export interface SurveyInviteResponse { survey_url: string } +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 +} + export const adminApi = { // Dashboard getDashboardMetrics: () => @@ -158,6 +173,12 @@ export const adminApi = { api.get('/admin/survey-invites').then(r => r.data), createSurveyInvite: (data: { recipient_name: string; recipient_email?: string; send_email?: boolean }) => api.post('/admin/survey-invites', data).then(r => r.data), + + // 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), } export default adminApi diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index 19ff1ec8..ca77d73c 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -9,6 +9,7 @@ import { Settings, FolderTree, ClipboardList, + MessageSquareText, ArrowLeft, } from 'lucide-react' import { cn } from '@/lib/utils' @@ -23,6 +24,7 @@ const navItems = [ { path: '/admin/settings', label: 'Settings', icon: Settings }, { path: '/admin/categories', label: 'Categories', icon: FolderTree }, { path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList }, + { path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText }, ] interface AdminSidebarProps { diff --git a/frontend/src/pages/SurveyPage.tsx b/frontend/src/pages/SurveyPage.tsx index 0a0e39bb..73043cec 100644 --- a/frontend/src/pages/SurveyPage.tsx +++ b/frontend/src/pages/SurveyPage.tsx @@ -96,16 +96,14 @@ const SLIDES: SurveySlideData[] = [ { id: 'ranking', questions: [ - { id: 'domain_rank', type: 'rank', num: '14', text: 'Drag to rank: which technical domains should FlowPilot handle first?', hint: 'Most important at the top.', + { id: 'prioritization', type: 'rank', num: '14', text: 'Rank these factors by how much they influence your diagnostic order.', hint: 'What drives which theory you investigate first? Drag to reorder — most influential at the top.', items: [ - "Windows Server / Active Directory", - "Microsoft 365 / Exchange", - "Networking (DNS, DHCP, VPN, Firewall)", - "Security / Compliance", - "Virtualization (Hyper-V, VMware)", - "Backup & Disaster Recovery", - "Cloud (Azure / AWS)", - "Endpoint Management" + "Likelihood of being the root cause", + "How fast I can test or rule it out", + "Blast radius — impact if it's the problem", + "How recently something changed", + "Tool / access availability right now", + "Past experience with similar symptoms" ]}, ] }, diff --git a/frontend/src/pages/admin/SurveyResponsesPage.tsx b/frontend/src/pages/admin/SurveyResponsesPage.tsx new file mode 100644 index 00000000..1a99a4a3 --- /dev/null +++ b/frontend/src/pages/admin/SurveyResponsesPage.tsx @@ -0,0 +1,322 @@ +import { useState, useEffect } from 'react' +import { adminApi, type SurveyResponseDetail, type SurveyResponseListResponse } from '@/api/admin' +import { PageHeader } from '@/components/admin' +import { ChevronDown, Download, User, Link2, Loader2 } from 'lucide-react' +import { cn } from '@/lib/utils' + +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' }, +] + +function AnswerDisplay({ value, type }: { value: string | string[] | undefined; type: string }) { + if (!value || (Array.isArray(value) && value.length === 0)) { + return

No answer

+ } + + if (type === 'mc-multi' && Array.isArray(value)) { + return ( +
+ {value.map((v, i) => ( + + {v} + + ))} +
+ ) + } + + if (type === 'rank' && Array.isArray(value)) { + return ( +
    + {value.map((v, i) => ( +
  1. + {i + 1}. + {v} +
  2. + ))} +
+ ) + } + + if (type === 'text') { + return ( +
+

{String(value)}

+
+ ) + } + + return

{String(value)}

+} + +function ExpandedDetail({ response }: { response: SurveyResponseDetail }) { + return ( + + +
+
+ {QUESTIONS.map((q) => ( +
+

+ Q{q.num} +

+

{q.text}

+ +
+ ))} +
+
+ + + ) +} + +function ResponseRow({ + response, + index, + isExpanded, + onToggle, +}: { + response: SurveyResponseDetail + index: number + isExpanded: boolean + onToggle: () => void +}) { + const answeredCount = QUESTIONS.filter((q) => { + const val = response.responses[q.id] + return val !== undefined && val !== null && val !== '' && !(Array.isArray(val) && val.length === 0) + }).length + + return ( + <> + + + + + {index + 1} + + {response.respondent_name || Anonymous} + + + {response.source === 'invite' ? ( + + + Invite + {response.invite_name && ( + ({response.invite_name}) + )} + + ) : ( + + + Direct + + )} + + + {new Date(response.created_at).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + + {answeredCount} / {QUESTIONS.length} + + + {isExpanded && } + + ) +} + +export default function SurveyResponsesPage() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [expandedId, setExpandedId] = useState(null) + const [exporting, setExporting] = useState(false) + + useEffect(() => { + const fetchData = async () => { + try { + const result = await adminApi.listSurveyResponses() + setData(result) + } catch { + setError('Failed to load survey responses') + } finally { + setLoading(false) + } + } + fetchData() + }, []) + + 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) + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (error && !data) { + return ( +
+
{error}
+
+ ) + } + + const responses = data?.responses ?? [] + + return ( +
+ + {exporting ? ( + + ) : ( + + )} + Export CSV + + } + /> + + {error && ( +
+ {error} +
+ )} + + {/* Stat cards */} +
+
+

+ Total Responses +

+

+ {data?.total ?? 0} +

+
+
+

+ This Week +

+

+ {data?.this_week ?? 0} +

+
+
+ + {/* Table */} +
+ + + + + + + + + + + + {responses.length === 0 ? ( + + + + ) : ( + responses.map((response, index) => ( + + setExpandedId(expandedId === response.id ? null : response.id) + } + /> + )) + )} + +
+ + # + + Respondent + + Source + + Date + + Answered +
+ No survey responses yet. +
+
+
+ ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 0b530816..ea4dc1ce 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -52,6 +52,7 @@ const AdminFeatureFlagsPage = lazy(() => import('@/pages/admin/FeatureFlagsPage' const AdminSettingsPage = lazy(() => import('@/pages/admin/SettingsPage')) const AdminGlobalCategoriesPage = lazy(() => import('@/pages/admin/GlobalCategoriesPage')) const AdminSurveyInvitesPage = lazy(() => import('@/pages/admin/SurveyInvitesPage')) +const AdminSurveyResponsesPage = lazy(() => import('@/pages/admin/SurveyResponsesPage')) // Account pages const AccountLayout = lazy(() => import('@/components/account/AccountLayout')) @@ -403,6 +404,14 @@ export const router = createBrowserRouter([ ), }, + { + path: 'survey-responses', + element: ( + }> + + + ), + }, ], }, // Account routes