* 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>
393 lines
12 KiB
Python
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"},
|
|
)
|