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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user