Files
resolutionflow/backend/app/api/endpoints/admin_survey.py
chihlasm 0fb1ef33a0 feat: AI chat conclusion + survey completion & management (#95)
* fix: increase assistant chat input height from 1 to 3 rows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add Anthropic prompt caching to assistant chat

Cache the static system prompt and conversation history prefix across
turns, reducing input token costs by ~80% on multi-turn conversations.
RAG context is intentionally uncached since it changes per query.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add Microsoft Learn MCP integration + refine assistant system prompt

- Integrate Microsoft Learn MCP server via Anthropic's MCP connector
  for real-time documentation lookups (docs search, fetch, code samples)
- Refine system prompt: clear persona, structured answer guidelines,
  when to use RAG flows vs Microsoft Learn, guardrails against fabrication
- Add ENABLE_MCP_MICROSOFT_LEARN config toggle (default: True)
- Fix bugs from prior edit: wrong MCP URL, broken indentation, undefined
  usage/token variables, NOT_GIVEN for disabled MCP params
- Log MCP tool usage and cache performance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: AI chat session conclusion + survey completion & management

AI Assistant - Conclude Session:
- 3-step modal: select outcome (resolved/escalated/paused), add notes, AI-generated summary
- AI generates structured ticket notes from conversation transcript (PSA-ready format)
- Copy to clipboard for pasting into ticketing systems
- "Resume in New Chat" for paused sessions (pre-loads context into new chat)
- Backend: POST /chats/{id}/conclude endpoint, conclusion_summary/outcome/concluded_at fields
- Migration 048: add conclusion fields to assistant_chats

Survey Completion Flow:
- Email-to-self option after submission (branded HTML email with formatted responses)
- Finish button navigates to /survey/thank-you page
- Thank you page with close-window message and feedback email callout
- Already-submitted state updated with same messaging
- Backend: POST /survey/email-copy public endpoint

Survey Admin Management:
- Read/unread indicators (cyan dot, bold name, auto-mark on expand)
- Unread count stat card
- Per-row context menu: mark read/unread, archive/unarchive, delete
- Bulk actions bar: select all, mark read/unread, archive, delete
- Show Archived toggle to filter archived responses
- Backend: 7 new admin endpoints (read, unread, archive, unarchive, delete, bulk)
- Migration 049: add is_read, archived_at to survey_responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: initialize VerifyEmailPage state from token to avoid setState in effect

Moves the no-token error case from useEffect into initial state to satisfy
the react-hooks/set-state-in-effect ESLint rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:43:02 -05:00

393 lines
12 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 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"])
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)],
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"},
)