spine-framework-portal 0.2.21 → 0.2.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/KBGenerator.tsx +1 -3
- package/functions/custom_support-triage.ts +2 -0
- package/hooks/useCommunity.ts +1 -5
- package/hooks/useItemProgress.ts +2 -2
- package/hooks/usePortalThreads.ts +3 -13
- package/hooks/useTickets.ts +12 -5
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/pages/CoursesPage.tsx +4 -13
- package/pages/TicketsPage.tsx +25 -0
- package/hooks/useTypeRegistry.ts +0 -74
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
|
-
import { getTypeIdAsync } from '../hooks/useTypeRegistry'
|
|
3
2
|
import { apiFetch } from '@core/lib/api'
|
|
4
3
|
import { Card, CardContent, CardHeader } from '@core/components/ui/card'
|
|
5
4
|
import { Button } from '@core/components/ui/button'
|
|
@@ -40,13 +39,12 @@ export function KBGenerator({ ticket, onGenerated, onCancel }: KBGeneratorProps)
|
|
|
40
39
|
const handleSave = async () => {
|
|
41
40
|
if (!generatedArticle) return
|
|
42
41
|
try {
|
|
43
|
-
const kbArticleTypeId = await getTypeIdAsync('kb_article')
|
|
44
42
|
const res = await apiFetch('/.netlify/functions/admin-data', {
|
|
45
43
|
method: 'POST',
|
|
46
44
|
headers: { 'Content-Type': 'application/json' },
|
|
47
45
|
body: JSON.stringify({
|
|
48
46
|
entity: 'items',
|
|
49
|
-
|
|
47
|
+
type_slug: 'kb_article',
|
|
50
48
|
title: generatedArticle.title,
|
|
51
49
|
status: 'published',
|
|
52
50
|
description: editedContent,
|
|
@@ -89,6 +89,7 @@ async function handleNewTicket(
|
|
|
89
89
|
data: {
|
|
90
90
|
aim_triage_agent_id: triageAgentId,
|
|
91
91
|
aim_confidence_at_response: confidence,
|
|
92
|
+
aim_retrieval_confidence: agentMsg?.data?.retrieval_confidence ?? null,
|
|
92
93
|
aim_escalation_reason: escalated ? 'low_confidence' : 'none',
|
|
93
94
|
}
|
|
94
95
|
})
|
|
@@ -131,6 +132,7 @@ async function handleReply(
|
|
|
131
132
|
data: {
|
|
132
133
|
...(currentTicket?.data || {}),
|
|
133
134
|
aim_confidence_at_response: confidence,
|
|
135
|
+
aim_retrieval_confidence: agentMsg?.data?.retrieval_confidence ?? null,
|
|
134
136
|
aim_escalation_reason: 'low_confidence',
|
|
135
137
|
}
|
|
136
138
|
})
|
package/hooks/useCommunity.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from 'react'
|
|
2
2
|
import { apiFetch } from '@core/lib/api'
|
|
3
|
-
import { getTypeIdAsync } from './useTypeRegistry'
|
|
4
3
|
|
|
5
4
|
export interface CommunityPost {
|
|
6
5
|
id: string
|
|
@@ -59,12 +58,9 @@ export function useCreatePost() {
|
|
|
59
58
|
setLoading(true)
|
|
60
59
|
setError(null)
|
|
61
60
|
try {
|
|
62
|
-
const typeId = await getTypeIdAsync('community_post')
|
|
63
|
-
if (!typeId) throw new Error('community_post type not found')
|
|
64
|
-
|
|
65
61
|
return await fetchJSON('/.netlify/functions/admin-data', {
|
|
66
62
|
method: 'POST',
|
|
67
|
-
body: JSON.stringify({ entity: 'items',
|
|
63
|
+
body: JSON.stringify({ entity: 'items', type_slug: 'community_post', ...fields }),
|
|
68
64
|
})
|
|
69
65
|
} catch (e: any) {
|
|
70
66
|
setError(e.message)
|
package/hooks/useItemProgress.ts
CHANGED
|
@@ -76,7 +76,7 @@ export function useUpsertProgress() {
|
|
|
76
76
|
const upsert = useCallback(async (params: {
|
|
77
77
|
personId: string
|
|
78
78
|
itemId: string
|
|
79
|
-
|
|
79
|
+
typeSlug: string
|
|
80
80
|
accountId: string
|
|
81
81
|
appId?: string
|
|
82
82
|
status?: string
|
|
@@ -92,7 +92,7 @@ export function useUpsertProgress() {
|
|
|
92
92
|
body: JSON.stringify({
|
|
93
93
|
person_id: params.personId,
|
|
94
94
|
item_id: params.itemId,
|
|
95
|
-
|
|
95
|
+
type_slug: params.typeSlug,
|
|
96
96
|
account_id: params.accountId,
|
|
97
97
|
app_id: params.appId,
|
|
98
98
|
status: params.status,
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from 'react'
|
|
2
2
|
import { apiFetch } from '@core/lib/api'
|
|
3
|
-
import { getTypeIdAsync } from './useTypeRegistry'
|
|
4
3
|
|
|
5
4
|
export interface PortalMessage {
|
|
6
5
|
id: string
|
|
@@ -63,11 +62,8 @@ export function usePortalThread(targetType: string, targetId: string | null, dom
|
|
|
63
62
|
setLoading(true)
|
|
64
63
|
setError(null)
|
|
65
64
|
try {
|
|
66
|
-
const threadTypeId = await getTypeIdAsync(threadTypeSlug)
|
|
67
|
-
if (!threadTypeId) throw new Error(`${threadTypeSlug} type not found`)
|
|
68
|
-
|
|
69
65
|
const threads = await fetchJSON(
|
|
70
|
-
`/.netlify/functions/admin-data?entity=threads&target_type=${targetType}&target_id=${targetId}&
|
|
66
|
+
`/.netlify/functions/admin-data?entity=threads&target_type=${targetType}&target_id=${targetId}&type_slug=${threadTypeSlug}`
|
|
71
67
|
)
|
|
72
68
|
const found: PortalThread | null = Array.isArray(threads) ? (threads[0] ?? null) : null
|
|
73
69
|
setThread(found)
|
|
@@ -92,14 +88,11 @@ export function usePortalThread(targetType: string, targetId: string | null, dom
|
|
|
92
88
|
let activeThread = thread
|
|
93
89
|
|
|
94
90
|
if (!activeThread?.id) {
|
|
95
|
-
const threadTypeId = await getTypeIdAsync(threadTypeSlug)
|
|
96
|
-
if (!threadTypeId) throw new Error(`${threadTypeSlug} type not found`)
|
|
97
|
-
|
|
98
91
|
activeThread = await fetchJSON('/.netlify/functions/admin-data', {
|
|
99
92
|
method: 'POST',
|
|
100
93
|
body: JSON.stringify({
|
|
101
94
|
entity: 'threads',
|
|
102
|
-
|
|
95
|
+
type_slug: threadTypeSlug,
|
|
103
96
|
target_type: targetType,
|
|
104
97
|
target_id: targetId,
|
|
105
98
|
status: 'open',
|
|
@@ -108,9 +101,6 @@ export function usePortalThread(targetType: string, targetId: string | null, dom
|
|
|
108
101
|
setThread(activeThread)
|
|
109
102
|
}
|
|
110
103
|
|
|
111
|
-
const messageTypeId = await getTypeIdAsync(messageTypeSlug)
|
|
112
|
-
if (!messageTypeId) throw new Error(`${messageTypeSlug} type not found`)
|
|
113
|
-
|
|
114
104
|
// Calculate next sequence based on current messages to avoid stale state
|
|
115
105
|
const nextSequence = messages.reduce((max, m) => Math.max(max, m.sequence || 0), 0) + 1
|
|
116
106
|
|
|
@@ -118,7 +108,7 @@ export function usePortalThread(targetType: string, targetId: string | null, dom
|
|
|
118
108
|
method: 'POST',
|
|
119
109
|
body: JSON.stringify({
|
|
120
110
|
entity: 'messages',
|
|
121
|
-
|
|
111
|
+
type_slug: messageTypeSlug,
|
|
122
112
|
thread_id: activeThread!.id,
|
|
123
113
|
content,
|
|
124
114
|
direction: 'inbound',
|
package/hooks/useTickets.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from 'react'
|
|
2
2
|
import { apiFetch } from '@core/lib/api'
|
|
3
|
-
import { getTypeIdAsync } from './useTypeRegistry'
|
|
4
3
|
|
|
5
4
|
export interface Ticket {
|
|
6
5
|
id: string
|
|
@@ -77,12 +76,9 @@ export function useCreateTicket() {
|
|
|
77
76
|
setLoading(true)
|
|
78
77
|
setError(null)
|
|
79
78
|
try {
|
|
80
|
-
const typeId = await getTypeIdAsync('support_ticket')
|
|
81
|
-
if (!typeId) throw new Error('support_ticket type not found')
|
|
82
|
-
|
|
83
79
|
return await fetchJSON('/.netlify/functions/admin-data', {
|
|
84
80
|
method: 'POST',
|
|
85
|
-
body: JSON.stringify({ entity: 'items',
|
|
81
|
+
body: JSON.stringify({ entity: 'items', type_slug: 'support_ticket', ...fields }),
|
|
86
82
|
})
|
|
87
83
|
} catch (e: any) {
|
|
88
84
|
setError(e.message)
|
|
@@ -191,6 +187,17 @@ export function useSubmitFeedback() {
|
|
|
191
187
|
}
|
|
192
188
|
}),
|
|
193
189
|
})
|
|
190
|
+
|
|
191
|
+
// Best-effort: record feedback in the core ai-interactions log
|
|
192
|
+
await fetchJSON('/.netlify/functions/ai-interactions', {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
body: JSON.stringify({
|
|
195
|
+
action: 'submit_feedback',
|
|
196
|
+
message_id: messageId,
|
|
197
|
+
ticket_id: ticketId,
|
|
198
|
+
feedback: feedback === 'up' ? 'helpful' : 'not_helpful',
|
|
199
|
+
}),
|
|
200
|
+
}).catch(() => null) // best-effort — don't fail the feedback submission if this fails
|
|
194
201
|
|
|
195
202
|
// If thumbs down, escalate to human
|
|
196
203
|
if (feedback === 'down') {
|
package/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "Portal",
|
|
3
3
|
"slug": "portal",
|
|
4
4
|
"description": "Self-service portal for customers to access tickets, knowledge base, courses, and community",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.23",
|
|
6
6
|
"app_type": "full",
|
|
7
7
|
"route_prefix": "/portal",
|
|
8
8
|
"required_roles": ["member", "member_admin"],
|
package/package.json
CHANGED
package/pages/CoursesPage.tsx
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { useState, useEffect
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
2
|
import { CheckCircle, Circle, PlayCircle, GraduationCap, Send } from 'lucide-react'
|
|
3
3
|
import { useCourseLessons, type CourseItem } from '../hooks/useCourses'
|
|
4
4
|
import { usePortalSignal } from '../hooks/usePortalSignal'
|
|
5
5
|
import { useItemProgress, useUpsertProgress } from '../hooks/useItemProgress'
|
|
6
6
|
import { usePortalThread } from '../hooks/usePortalThreads'
|
|
7
|
-
import { getTypeIdAsync } from '../hooks/useTypeRegistry'
|
|
8
7
|
import { useAuth } from '@core/contexts/AuthContext'
|
|
9
8
|
import { SearchFilterBar } from '../components/SearchFilterBar'
|
|
10
9
|
import { Button } from '@core/components/ui/button'
|
|
@@ -42,7 +41,6 @@ export function CoursesPage() {
|
|
|
42
41
|
const [selectedId, setSelectedId] = useState<string | null>(() => localStorage.getItem(LAST_LESSON_KEY))
|
|
43
42
|
const [replyText, setReplyText] = useState('')
|
|
44
43
|
const [replying, setReplying] = useState(false)
|
|
45
|
-
const progressTypeIdRef = useRef<string | null>(null)
|
|
46
44
|
|
|
47
45
|
const { lessons, loading, error } = useCourseLessons()
|
|
48
46
|
const lessonIds = lessons.map((l) => l.id)
|
|
@@ -60,10 +58,6 @@ export function CoursesPage() {
|
|
|
60
58
|
}
|
|
61
59
|
}, [lessons, selectedId])
|
|
62
60
|
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
getTypeIdAsync(PROGRESS_TYPE_SLUG).then((id) => { progressTypeIdRef.current = id })
|
|
65
|
-
}, [])
|
|
66
|
-
|
|
67
61
|
const courseMap = groupByCourse(lessons)
|
|
68
62
|
const courseNames = Array.from(courseMap.keys())
|
|
69
63
|
const filteredCourses = courseNames.filter((c) => c.toLowerCase().includes(search.toLowerCase()))
|
|
@@ -75,11 +69,10 @@ export function CoursesPage() {
|
|
|
75
69
|
const handleSelectLesson = async (lessonId: string) => {
|
|
76
70
|
setSelectedId(lessonId)
|
|
77
71
|
localStorage.setItem(LAST_LESSON_KEY, lessonId)
|
|
78
|
-
|
|
79
|
-
if (!typeId || !user?.id || !user?.account_id) return
|
|
72
|
+
if (!user?.id || !user?.account_id) return
|
|
80
73
|
const existing = progressMap.get(lessonId)
|
|
81
74
|
if (!existing || existing.status === 'not_started') {
|
|
82
|
-
await upsert({ personId: user.id, itemId: lessonId,
|
|
75
|
+
await upsert({ personId: user.id, itemId: lessonId, typeSlug: PROGRESS_TYPE_SLUG, accountId: user.account_id, status: 'in_progress' })
|
|
83
76
|
refetchProgress()
|
|
84
77
|
sendSignal('lesson_start', 'Started a course lesson')
|
|
85
78
|
}
|
|
@@ -87,9 +80,7 @@ export function CoursesPage() {
|
|
|
87
80
|
|
|
88
81
|
const handleComplete = async () => {
|
|
89
82
|
if (!selectedId || !user?.id || !user?.account_id) return
|
|
90
|
-
|
|
91
|
-
if (!typeId) return
|
|
92
|
-
await upsert({ personId: user.id, itemId: selectedId, typeId, accountId: user.account_id, status: 'completed' })
|
|
83
|
+
await upsert({ personId: user.id, itemId: selectedId, typeSlug: PROGRESS_TYPE_SLUG, accountId: user.account_id, status: 'completed' })
|
|
93
84
|
refetchProgress()
|
|
94
85
|
sendSignal('lesson_complete', 'Completed a course lesson')
|
|
95
86
|
}
|
package/pages/TicketsPage.tsx
CHANGED
|
@@ -40,8 +40,33 @@ function AIResponseCard({ message, ticketId, messageId, onFeedback }: {
|
|
|
40
40
|
<Bot size={14} className="text-primary" />
|
|
41
41
|
<span className="text-xs font-medium text-muted-foreground">Spine Assistant</span>
|
|
42
42
|
</div>
|
|
43
|
+
{message.data?.confidence !== undefined && (
|
|
44
|
+
<div className="text-xs text-muted-foreground mt-1">
|
|
45
|
+
{message.data.confidence >= 0.85
|
|
46
|
+
? 'AI answered'
|
|
47
|
+
: message.data.confidence >= 0.60
|
|
48
|
+
? 'AI drafted · reviewed by support'
|
|
49
|
+
: 'Human answered'}
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
43
52
|
<div className="leading-relaxed">{message.content}</div>
|
|
44
53
|
|
|
54
|
+
{Array.isArray(message.data?.retrieved_sources) && message.data.retrieved_sources.length > 0 && (
|
|
55
|
+
<details className="mt-2 text-xs text-muted-foreground">
|
|
56
|
+
<summary className="cursor-pointer hover:text-foreground">
|
|
57
|
+
Sources ({message.data.retrieved_sources.length})
|
|
58
|
+
</summary>
|
|
59
|
+
<ul className="mt-1 space-y-0.5 pl-2">
|
|
60
|
+
{message.data.retrieved_sources.slice(0, 3).map((s: any, i: number) => (
|
|
61
|
+
<li key={i} className="truncate">
|
|
62
|
+
{s.title || s.document_id}
|
|
63
|
+
{s.similarity ? ` — ${Math.round(s.similarity * 100)}%` : ''}
|
|
64
|
+
</li>
|
|
65
|
+
))}
|
|
66
|
+
</ul>
|
|
67
|
+
</details>
|
|
68
|
+
)}
|
|
69
|
+
|
|
45
70
|
{!feedback && (
|
|
46
71
|
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border/50">
|
|
47
72
|
<span className="text-xs text-muted-foreground">Was this helpful?</span>
|
package/hooks/useTypeRegistry.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react'
|
|
2
|
-
import { supabase } from '@core/lib/supabase'
|
|
3
|
-
|
|
4
|
-
interface TypeRecord {
|
|
5
|
-
id: string
|
|
6
|
-
slug: string
|
|
7
|
-
name: string
|
|
8
|
-
description?: string
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
// Module-level cache - persists across renders, shared across hooks
|
|
12
|
-
let typeCache: Map<string, TypeRecord> | null = null
|
|
13
|
-
let loadPromise: Promise<Map<string, TypeRecord>> | null = null
|
|
14
|
-
|
|
15
|
-
async function fetchTypes(): Promise<Map<string, TypeRecord>> {
|
|
16
|
-
if (typeCache) return typeCache
|
|
17
|
-
if (loadPromise) return loadPromise
|
|
18
|
-
|
|
19
|
-
loadPromise = (async () => {
|
|
20
|
-
try {
|
|
21
|
-
// Query Supabase directly - types is a config table, not exposed via admin-data
|
|
22
|
-
const { data: types, error } = await supabase
|
|
23
|
-
.from('types')
|
|
24
|
-
.select('id, slug, name, description')
|
|
25
|
-
.eq('is_active', true)
|
|
26
|
-
.limit(100)
|
|
27
|
-
|
|
28
|
-
if (error) throw error
|
|
29
|
-
|
|
30
|
-
typeCache = new Map((types || []).map(t => [t.slug, t]))
|
|
31
|
-
return typeCache
|
|
32
|
-
} catch (e) {
|
|
33
|
-
console.error('Failed to load types:', e)
|
|
34
|
-
typeCache = new Map() // Empty cache on error
|
|
35
|
-
return typeCache
|
|
36
|
-
} finally {
|
|
37
|
-
loadPromise = null
|
|
38
|
-
}
|
|
39
|
-
})()
|
|
40
|
-
|
|
41
|
-
return loadPromise
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function useTypeRegistry() {
|
|
45
|
-
const [types, setTypes] = useState<Map<string, TypeRecord>>(typeCache || new Map())
|
|
46
|
-
const [loading, setLoading] = useState(!typeCache)
|
|
47
|
-
const [error, setError] = useState<string | null>(null)
|
|
48
|
-
|
|
49
|
-
useEffect(() => {
|
|
50
|
-
if (typeCache) return
|
|
51
|
-
|
|
52
|
-
fetchTypes()
|
|
53
|
-
.then(cache => {
|
|
54
|
-
setTypes(cache)
|
|
55
|
-
setLoading(false)
|
|
56
|
-
})
|
|
57
|
-
.catch(e => {
|
|
58
|
-
setError(e.message)
|
|
59
|
-
setLoading(false)
|
|
60
|
-
})
|
|
61
|
-
}, [])
|
|
62
|
-
|
|
63
|
-
const getTypeId = (slug: string): string | null => {
|
|
64
|
-
return types.get(slug)?.id || null
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return { types, loading, error, getTypeId }
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Synchronous version for use inside async functions
|
|
71
|
-
export async function getTypeIdAsync(slug: string): Promise<string | null> {
|
|
72
|
-
const cache = await fetchTypes()
|
|
73
|
-
return cache.get(slug)?.id || null
|
|
74
|
-
}
|