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>
This commit is contained in:
Michael Chihlas
2026-03-05 20:00:28 -05:00
parent e4c5948fbd
commit 882f67f42e
20 changed files with 1627 additions and 63 deletions

View File

@@ -4,10 +4,11 @@ import io
import logging
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any
from uuid import UUID
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy import select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_admin
@@ -96,6 +97,7 @@ async def list_survey_invites(
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 = (
@@ -103,11 +105,15 @@ async def list_survey_responses(
.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:
@@ -119,19 +125,168 @@ async def list_survey_responses(
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",