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>
This commit was merged in pull request #95.
This commit is contained in:
chihlasm
2026-03-05 22:43:02 -05:00
committed by GitHub
parent b46f41e7bb
commit 0fb1ef33a0
21 changed files with 1630 additions and 70 deletions

View File

@@ -1,8 +1,24 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { adminApi, type SurveyResponseDetail, type SurveyResponseListResponse } from '@/api/admin'
import { PageHeader } from '@/components/admin'
import { ChevronDown, Download, User, Link2, Loader2 } from 'lucide-react'
import {
ChevronDown,
Download,
User,
Link2,
Loader2,
Eye,
EyeOff,
Archive,
ArchiveRestore,
Trash2,
CheckSquare,
Square,
MoreHorizontal,
Circle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
const QUESTIONS: { id: string; num: string; text: string; type: 'mc' | 'mc-multi' | 'range' | 'text' | 'rank' }[] = [
{ id: 'prereqs', num: '1', text: 'Before you start troubleshooting, what info do you need?', type: 'mc-multi' },
@@ -70,7 +86,7 @@ function AnswerDisplay({ value, type }: { value: string | string[] | undefined;
function ExpandedDetail({ response }: { response: SurveyResponseDetail }) {
return (
<tr>
<td colSpan={6} className="p-0">
<td colSpan={8} className="p-0">
<div
className="px-6 py-5"
style={{
@@ -106,25 +122,57 @@ function ResponseRow({
response,
index,
isExpanded,
isSelected,
onToggle,
onSelect,
onMarkRead,
onArchive,
onDelete,
}: {
response: SurveyResponseDetail
index: number
isExpanded: boolean
isSelected: boolean
onToggle: () => void
onSelect: () => void
onMarkRead: () => void
onArchive: () => void
onDelete: () => void
}) {
const answeredCount = QUESTIONS.filter((q) => {
const val = response.responses[q.id]
return val !== undefined && val !== null && val !== '' && !(Array.isArray(val) && val.length === 0)
}).length
const [showMenu, setShowMenu] = useState(false)
return (
<>
<tr
className="border-b border-border/50 hover:bg-[rgba(255,255,255,0.02)] transition-colors cursor-pointer"
onClick={onToggle}
className={cn(
'border-b border-border/50 transition-colors cursor-pointer',
!response.is_read && 'bg-primary/[0.03]',
'hover:bg-[rgba(255,255,255,0.02)]'
)}
>
<td className="px-4 py-3 w-8">
{/* Checkbox */}
<td className="px-2 py-3 w-8" onClick={e => { e.stopPropagation(); onSelect() }}>
{isSelected ? (
<CheckSquare className="h-4 w-4 text-primary cursor-pointer" />
) : (
<Square className="h-4 w-4 text-muted-foreground/40 cursor-pointer hover:text-muted-foreground" />
)}
</td>
{/* Unread dot */}
<td className="px-1 py-3 w-6" onClick={onToggle}>
{!response.is_read && (
<Circle className="h-2.5 w-2.5 fill-primary text-primary" />
)}
</td>
{/* Expand chevron */}
<td className="px-2 py-3 w-8" onClick={onToggle}>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
@@ -132,11 +180,11 @@ function ResponseRow({
)}
/>
</td>
<td className="px-4 py-3 font-label text-xs text-muted-foreground">{index + 1}</td>
<td className="px-4 py-3 text-sm text-foreground">
<td className="px-4 py-3 font-label text-xs text-muted-foreground" onClick={onToggle}>{index + 1}</td>
<td className={cn('px-4 py-3 text-sm', !response.is_read ? 'text-foreground font-medium' : 'text-foreground')} onClick={onToggle}>
{response.respondent_name || <span className="text-muted-foreground italic">Anonymous</span>}
</td>
<td className="px-4 py-3">
<td className="px-4 py-3" onClick={onToggle}>
{response.source === 'invite' ? (
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wider bg-primary/10 text-primary">
<User className="h-3 w-3" />
@@ -152,16 +200,57 @@ function ResponseRow({
</span>
)}
</td>
<td className="px-4 py-3 font-label text-xs text-muted-foreground">
<td className="px-4 py-3 font-label text-xs text-muted-foreground" onClick={onToggle}>
{new Date(response.created_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
<td className="px-4 py-3 text-sm text-muted-foreground" onClick={onToggle}>
{answeredCount} / {QUESTIONS.length}
</td>
{/* Actions */}
<td className="px-3 py-3 w-10 relative">
<button
onClick={e => { e.stopPropagation(); setShowMenu(!showMenu) }}
className="p-1.5 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
>
<MoreHorizontal className="h-4 w-4" />
</button>
{showMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
<div
className="absolute right-3 top-full z-50 mt-1 w-44 rounded-xl py-1 shadow-xl"
style={{ background: 'rgba(24, 26, 31, 0.95)', border: '1px solid var(--glass-border)', backdropFilter: 'blur(16px)' }}
>
<button
onClick={() => { onMarkRead(); setShowMenu(false) }}
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
>
{response.is_read ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
{response.is_read ? 'Mark Unread' : 'Mark Read'}
</button>
<button
onClick={() => { onArchive(); setShowMenu(false) }}
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
>
{response.archived_at ? <ArchiveRestore className="h-3.5 w-3.5" /> : <Archive className="h-3.5 w-3.5" />}
{response.archived_at ? 'Unarchive' : 'Archive'}
</button>
<div className="my-1 border-t" style={{ borderColor: 'var(--glass-border)' }} />
<button
onClick={() => { onDelete(); setShowMenu(false) }}
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-rose-400 hover:bg-rose-500/10 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
</div>
</>
)}
</td>
</tr>
{isExpanded && <ExpandedDetail response={response} />}
</>
@@ -174,20 +263,24 @@ export default function SurveyResponsesPage() {
const [error, setError] = useState<string | null>(null)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showArchived, setShowArchived] = useState(false)
const fetchData = useCallback(async () => {
try {
const result = await adminApi.listSurveyResponses(showArchived)
setData(result)
} catch {
setError('Failed to load survey responses')
} finally {
setLoading(false)
}
}, [showArchived])
useEffect(() => {
const fetchData = async () => {
try {
const result = await adminApi.listSurveyResponses()
setData(result)
} catch {
setError('Failed to load survey responses')
} finally {
setLoading(false)
}
}
setLoading(true)
fetchData()
}, [])
}, [fetchData])
const handleExport = async () => {
setExporting(true)
@@ -206,6 +299,112 @@ export default function SurveyResponsesPage() {
}
}
const handleMarkRead = async (id: string, currentlyRead: boolean) => {
try {
if (currentlyRead) {
await adminApi.markResponseUnread(id)
} else {
await adminApi.markResponseRead(id)
}
setData(prev => prev ? {
...prev,
unread: prev.unread + (currentlyRead ? 1 : -1),
responses: prev.responses.map(r => r.id === id ? { ...r, is_read: !currentlyRead } : r),
} : prev)
} catch {
toast.error('Failed to update read status')
}
}
const handleArchive = async (id: string, currentlyArchived: boolean) => {
try {
if (currentlyArchived) {
await adminApi.unarchiveResponse(id)
setData(prev => prev ? {
...prev,
responses: prev.responses.map(r => r.id === id ? { ...r, archived_at: null } : r),
} : prev)
} else {
await adminApi.archiveResponse(id)
if (!showArchived) {
setData(prev => prev ? {
...prev,
total: prev.total - 1,
responses: prev.responses.filter(r => r.id !== id),
} : prev)
} else {
setData(prev => prev ? {
...prev,
responses: prev.responses.map(r => r.id === id ? { ...r, archived_at: new Date().toISOString() } : r),
} : prev)
}
}
toast.success(currentlyArchived ? 'Response unarchived' : 'Response archived')
} catch {
toast.error('Failed to update archive status')
}
}
const handleDelete = async (id: string) => {
if (!confirm('Permanently delete this response? This cannot be undone.')) return
try {
await adminApi.deleteResponse(id)
setData(prev => prev ? {
...prev,
total: prev.total - 1,
responses: prev.responses.filter(r => r.id !== id),
} : prev)
setSelectedIds(prev => { const next = new Set(prev); next.delete(id); return next })
toast.success('Response deleted')
} catch {
toast.error('Failed to delete response')
}
}
const handleBulkAction = async (action: string) => {
if (selectedIds.size === 0) return
if (action === 'delete' && !confirm(`Permanently delete ${selectedIds.size} response(s)?`)) return
try {
await adminApi.bulkActionResponses(action, Array.from(selectedIds))
setSelectedIds(new Set())
fetchData()
toast.success(`${action.replace('_', ' ')} applied to ${selectedIds.size} response(s)`)
} catch {
toast.error('Bulk action failed')
}
}
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
const responses = data?.responses ?? []
if (selectedIds.size === responses.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(responses.map(r => r.id)))
}
}
// Auto-mark as read when expanding
const handleExpand = (id: string) => {
const newId = expandedId === id ? null : id
setExpandedId(newId)
if (newId) {
const resp = data?.responses.find(r => r.id === newId)
if (resp && !resp.is_read) {
handleMarkRead(newId, false)
}
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@@ -230,18 +429,33 @@ export default function SurveyResponsesPage() {
title="Survey Responses"
description={`${data?.total ?? 0} total responses collected`}
action={
<button
onClick={handleExport}
disabled={exporting || responses.length === 0}
className="inline-flex items-center gap-2 rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-[rgba(255,255,255,0.12)] disabled:opacity-50"
>
{exporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Export CSV
</button>
<div className="flex items-center gap-2">
{/* Archive toggle */}
<button
onClick={() => setShowArchived(!showArchived)}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-3 py-2 text-xs font-medium transition-colors border',
showArchived
? 'bg-primary/10 text-primary border-primary/20'
: 'bg-[rgba(255,255,255,0.04)] text-muted-foreground border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)]'
)}
>
<Archive className="h-3.5 w-3.5" />
{showArchived ? 'Showing Archived' : 'Show Archived'}
</button>
<button
onClick={handleExport}
disabled={exporting || responses.length === 0}
className="inline-flex items-center gap-2 rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-[rgba(255,255,255,0.12)] disabled:opacity-50"
>
{exporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Export CSV
</button>
</div>
}
/>
@@ -269,14 +483,82 @@ export default function SurveyResponsesPage() {
{data?.this_week ?? 0}
</p>
</div>
<div className="glass-card-static px-5 py-4 flex-1">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1">
Unread
</p>
<p className={cn(
'text-2xl font-heading font-bold',
(data?.unread ?? 0) > 0 ? 'text-primary' : 'text-foreground'
)}>
{data?.unread ?? 0}
</p>
</div>
</div>
{/* Bulk actions bar */}
{selectedIds.size > 0 && (
<div
className="flex items-center gap-3 rounded-xl px-4 py-2.5"
style={{ background: 'rgba(6, 182, 212, 0.08)', border: '1px solid rgba(6, 182, 212, 0.15)' }}
>
<span className="text-sm text-primary font-medium">
{selectedIds.size} selected
</span>
<div className="flex-1" />
<button
onClick={() => handleBulkAction('mark_read')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<Eye className="h-3.5 w-3.5" />
Mark Read
</button>
<button
onClick={() => handleBulkAction('mark_unread')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<EyeOff className="h-3.5 w-3.5" />
Mark Unread
</button>
<button
onClick={() => handleBulkAction('archive')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<Archive className="h-3.5 w-3.5" />
Archive
</button>
<button
onClick={() => handleBulkAction('delete')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-rose-400 hover:bg-rose-500/10 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
Clear
</button>
</div>
)}
{/* Table */}
<div className="glass-card-static overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border/50">
<th className="px-4 py-3 w-8" />
<th className="px-2 py-3 w-8">
<button onClick={toggleSelectAll} className="text-muted-foreground/40 hover:text-muted-foreground">
{selectedIds.size > 0 && selectedIds.size === responses.length ? (
<CheckSquare className="h-4 w-4 text-primary" />
) : (
<Square className="h-4 w-4" />
)}
</button>
</th>
<th className="px-1 py-3 w-6" />
<th className="px-2 py-3 w-8" />
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
#
</th>
@@ -292,13 +574,14 @@ export default function SurveyResponsesPage() {
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Answered
</th>
<th className="px-3 py-3 w-10" />
</tr>
</thead>
<tbody>
{responses.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-sm text-muted-foreground">
No survey responses yet.
<td colSpan={9} className="px-4 py-12 text-center text-sm text-muted-foreground">
{showArchived ? 'No archived responses.' : 'No survey responses yet.'}
</td>
</tr>
) : (
@@ -308,9 +591,12 @@ export default function SurveyResponsesPage() {
response={response}
index={index}
isExpanded={expandedId === response.id}
onToggle={() =>
setExpandedId(expandedId === response.id ? null : response.id)
}
isSelected={selectedIds.has(response.id)}
onToggle={() => handleExpand(response.id)}
onSelect={() => toggleSelect(response.id)}
onMarkRead={() => handleMarkRead(response.id, response.is_read)}
onArchive={() => handleArchive(response.id, !!response.archived_at)}
onDelete={() => handleDelete(response.id)}
/>
))
)}