"""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 uuid import UUID from fastapi import APIRouter, Body, Depends, HTTPException, status from fastapi.responses import StreamingResponse from sqlalchemy import select, update, delete 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"]) def _get_frontend_url() -> str: if settings.FRONTEND_URL: return settings.FRONTEND_URL return "http://localhost:5173" if settings.DEBUG else "https://resolutionflow.com" def _build_invite_response(invite: SurveyInvite) -> SurveyInviteResponse: base_url = _get_frontend_url() 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 = _get_frontend_url() 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)], include_archived: bool = False, ): """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()) ) if not include_archived: stmt = stmt.where(SurveyResponse.archived_at.is_(None)) result = await db.execute(stmt) rows = result.all() one_week_ago = datetime.now(timezone.utc) - timedelta(days=7) this_week = 0 unread = 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, is_read=survey_resp.is_read, archived_at=survey_resp.archived_at, created_at=survey_resp.created_at, ) ) if survey_resp.created_at >= one_week_ago: this_week += 1 if not survey_resp.is_read: unread += 1 return SurveyResponseListResponse( responses=responses, total=len(responses), this_week=this_week, unread=unread, ) @router.put("/survey-responses/{response_id}/read", status_code=200) async def mark_response_read( response_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_admin)], ): """Mark a survey response as read.""" result = await db.execute( select(SurveyResponse).where(SurveyResponse.id == response_id) ) resp = result.scalar_one_or_none() if not resp: raise HTTPException(status_code=404, detail="Response not found") resp.is_read = True await db.commit() return {"id": str(resp.id), "is_read": True} @router.put("/survey-responses/{response_id}/unread", status_code=200) async def mark_response_unread( response_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_admin)], ): """Mark a survey response as unread.""" result = await db.execute( select(SurveyResponse).where(SurveyResponse.id == response_id) ) resp = result.scalar_one_or_none() if not resp: raise HTTPException(status_code=404, detail="Response not found") resp.is_read = False await db.commit() return {"id": str(resp.id), "is_read": False} @router.put("/survey-responses/{response_id}/archive", status_code=200) async def archive_response( response_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_admin)], ): """Archive a survey response.""" result = await db.execute( select(SurveyResponse).where(SurveyResponse.id == response_id) ) resp = result.scalar_one_or_none() if not resp: raise HTTPException(status_code=404, detail="Response not found") resp.archived_at = datetime.now(timezone.utc) await db.commit() return {"id": str(resp.id), "archived_at": resp.archived_at.isoformat()} @router.put("/survey-responses/{response_id}/unarchive", status_code=200) async def unarchive_response( response_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_admin)], ): """Unarchive a survey response.""" result = await db.execute( select(SurveyResponse).where(SurveyResponse.id == response_id) ) resp = result.scalar_one_or_none() if not resp: raise HTTPException(status_code=404, detail="Response not found") resp.archived_at = None await db.commit() return {"id": str(resp.id), "archived_at": None} @router.delete("/survey-responses/{response_id}", status_code=204) async def delete_response( response_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_admin)], ): """Permanently delete a survey response.""" result = await db.execute( select(SurveyResponse).where(SurveyResponse.id == response_id) ) resp = result.scalar_one_or_none() if not resp: raise HTTPException(status_code=404, detail="Response not found") await db.delete(resp) await db.commit() @router.post("/survey-responses/bulk", status_code=200) async def bulk_action_responses( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_admin)], action: str = Body(...), ids: list[str] = Body(...), ): """Bulk action on survey responses. Actions: mark_read, mark_unread, archive, delete.""" from uuid import UUID as _UUID uuids = [] for id_str in ids: try: uuids.append(_UUID(id_str)) except ValueError: continue if not uuids: raise HTTPException(status_code=400, detail="No valid IDs provided") if action == "mark_read": await db.execute( update(SurveyResponse) .where(SurveyResponse.id.in_(uuids)) .values(is_read=True) ) elif action == "mark_unread": await db.execute( update(SurveyResponse) .where(SurveyResponse.id.in_(uuids)) .values(is_read=False) ) elif action == "archive": await db.execute( update(SurveyResponse) .where(SurveyResponse.id.in_(uuids)) .values(archived_at=datetime.now(timezone.utc)) ) elif action == "delete": await db.execute( delete(SurveyResponse) .where(SurveyResponse.id.in_(uuids)) ) else: raise HTTPException(status_code=400, detail=f"Unknown action: {action}") await db.commit() return {"action": action, "count": len(uuids)} # 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"}, )