spine-framework-portal 0.1.10 → 0.2.1
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 +2 -2
- package/functions/custom_portal-community-escalation.ts +50 -35
- package/functions/custom_portal-signals.ts +37 -123
- package/hooks/useCommunity.ts +78 -0
- package/hooks/useCourses.ts +67 -0
- package/hooks/useItemProgress.ts +114 -0
- package/hooks/useKBArticles.ts +81 -0
- package/hooks/usePortalData.ts +102 -0
- package/hooks/usePortalHooks.ts +24 -0
- package/hooks/usePortalSignal.ts +82 -0
- package/hooks/usePortalThreads.ts +113 -0
- package/hooks/useTickets.ts +236 -0
- package/hooks/useTypeRegistry.ts +74 -0
- package/index.tsx +3 -1
- package/manifest.json +11 -4
- package/package.json +6 -9
- package/pages/DeveloperSettingsPage.tsx +269 -0
- package/pages/HomePage.tsx +16 -2
- package/pages/team/TeamPage.tsx +51 -0
- package/seed/roles.json +9 -0
- package/seed/types.json +2472 -88
- package/utils/identityCookie.ts +196 -0
- package/CHANGELOG.md +0 -13
- package/LICENSE.md +0 -223
- package/README.md +0 -58
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import { getTypeIdAsync } from '../hooks/useTypeRegistry'
|
|
3
3
|
import { apiFetch } from '@core/lib/api'
|
|
4
4
|
import { Card, CardContent, CardHeader } from '@core/components/ui/card'
|
|
5
5
|
import { Button } from '@core/components/ui/button'
|
|
@@ -40,7 +40,7 @@ export function KBGenerator({ ticket, onGenerated, onCancel }: KBGeneratorProps)
|
|
|
40
40
|
const handleSave = async () => {
|
|
41
41
|
if (!generatedArticle) return
|
|
42
42
|
try {
|
|
43
|
-
const kbArticleTypeId = await
|
|
43
|
+
const kbArticleTypeId = await getTypeIdAsync('kb_article')
|
|
44
44
|
const res = await apiFetch('/.netlify/functions/admin-data', {
|
|
45
45
|
method: 'POST',
|
|
46
46
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import { createHandler } from '../../../.framework/functions/_shared/middleware'
|
|
2
|
+
import { adminDb } from '../../../.framework/functions/_shared/db'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Portal Community Escalation Handler
|
|
6
|
+
*
|
|
7
|
+
* Triggered by cron schedule to convert unanswered community posts to support tickets.
|
|
8
|
+
* Posts unanswered for 24+ hours are escalated to the AI-first support queue.
|
|
9
|
+
*
|
|
10
|
+
* Uses adminDb (service role) for system-level operations across all accounts.
|
|
11
|
+
*/
|
|
8
12
|
|
|
9
13
|
interface CommunityPost {
|
|
10
14
|
id: string
|
|
@@ -94,35 +98,9 @@ async function escalatePostToTicket(post: CommunityPost): Promise<string | null>
|
|
|
94
98
|
})
|
|
95
99
|
.eq('id', post.id)
|
|
96
100
|
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
const { data: pipeline } = await adminDb
|
|
100
|
-
.from('pipelines')
|
|
101
|
-
.select('id')
|
|
102
|
-
.ilike('name', '%support%triage%')
|
|
103
|
-
.limit(1)
|
|
104
|
-
.maybeSingle()
|
|
105
|
-
|
|
106
|
-
if (pipeline) {
|
|
107
|
-
await adminDb.from('pipeline_executions').insert({
|
|
108
|
-
pipeline_id: pipeline.id,
|
|
109
|
-
target_type: 'items',
|
|
110
|
-
target_id: ticketId,
|
|
111
|
-
status: 'pending',
|
|
112
|
-
input_context: {
|
|
113
|
-
ticket_id: ticketId,
|
|
114
|
-
account_id: post.account_id,
|
|
115
|
-
title: post.title,
|
|
116
|
-
description: post.description || '',
|
|
117
|
-
source: 'community_escalation',
|
|
118
|
-
},
|
|
119
|
-
})
|
|
120
|
-
}
|
|
121
|
-
} catch (err) {
|
|
122
|
-
console.error('Failed to trigger triage pipeline:', err)
|
|
123
|
-
}
|
|
101
|
+
await triggerTriageAgent(ticketId, post.account_id, post.title, post.description)
|
|
124
102
|
|
|
125
|
-
console.log(`
|
|
103
|
+
console.log(`Successfully escalated post ${post.id} to ticket ${ticketId}`)
|
|
126
104
|
return ticketId
|
|
127
105
|
|
|
128
106
|
} catch (err) {
|
|
@@ -131,6 +109,43 @@ async function escalatePostToTicket(post: CommunityPost): Promise<string | null>
|
|
|
131
109
|
}
|
|
132
110
|
}
|
|
133
111
|
|
|
112
|
+
async function triggerTriageAgent(
|
|
113
|
+
ticketId: string,
|
|
114
|
+
accountId: string,
|
|
115
|
+
title: string,
|
|
116
|
+
content?: string
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
try {
|
|
119
|
+
const { data: pipeline } = await adminDb
|
|
120
|
+
.from('pipelines')
|
|
121
|
+
.select('id')
|
|
122
|
+
.ilike('name', '%support%triage%')
|
|
123
|
+
.limit(1)
|
|
124
|
+
.maybeSingle()
|
|
125
|
+
|
|
126
|
+
if (!pipeline) {
|
|
127
|
+
console.log('No support triage pipeline found, skipping auto-trigger')
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await adminDb.from('pipeline_executions').insert({
|
|
132
|
+
pipeline_id: pipeline.id,
|
|
133
|
+
target_type: 'items',
|
|
134
|
+
target_id: ticketId,
|
|
135
|
+
status: 'pending',
|
|
136
|
+
input_context: {
|
|
137
|
+
ticket_id: ticketId,
|
|
138
|
+
account_id: accountId,
|
|
139
|
+
title,
|
|
140
|
+
description: content || '',
|
|
141
|
+
source: 'community_escalation',
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.error('Failed to trigger triage agent:', err)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
134
149
|
export const checkUnanswered = createHandler(async (_ctx, _body) => {
|
|
135
150
|
console.log('[PortalEscalation] Starting community escalation check...')
|
|
136
151
|
|
|
@@ -1,45 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
// Portal users are always identified — no anonymous session handling.
|
|
4
|
-
// Standalone: no dependency on cortex functions.
|
|
5
|
-
|
|
6
|
-
import { createHandler } from './_shared/middleware'
|
|
7
|
-
import { adminDb } from './_shared/db'
|
|
8
|
-
import { resolveTypeIds, resolveAccountId } from './_shared/resolve-ids'
|
|
9
|
-
|
|
10
|
-
async function resolveIds() {
|
|
11
|
-
const [types, unidentifiedVisitorsAccountId] = await Promise.all([
|
|
12
|
-
resolveTypeIds([{ kind: 'item', slug: 'funnel_signal' }]),
|
|
13
|
-
resolveAccountId('unidentified-visitors'),
|
|
14
|
-
])
|
|
15
|
-
return {
|
|
16
|
-
FUNNEL_SIGNAL_TYPE_ID: types['item/funnel_signal'],
|
|
17
|
-
UNIDENTIFIED_VISITORS_ACCOUNT_ID: unidentifiedVisitorsAccountId,
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function ratingToTemperature(rating: number): 'cold' | 'warm' | 'hot' {
|
|
22
|
-
if (rating <= 2) return 'cold'
|
|
23
|
-
if (rating <= 3) return 'warm'
|
|
24
|
-
return 'hot'
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function calculateSimpleScore(actionValue: number): { calculated: number; rating: 1 | 2 | 3 | 4 | 5 } {
|
|
28
|
-
const calculated = actionValue
|
|
29
|
-
let rating: 1 | 2 | 3 | 4 | 5
|
|
30
|
-
if (calculated <= 1) rating = 1
|
|
31
|
-
else if (calculated <= 4) rating = 2
|
|
32
|
-
else if (calculated <= 8) rating = 3
|
|
33
|
-
else if (calculated <= 15) rating = 4
|
|
34
|
-
else rating = 5
|
|
35
|
-
return { calculated, rating }
|
|
36
|
-
}
|
|
1
|
+
import { createHandler } from '../../../.framework/functions/_shared/middleware'
|
|
2
|
+
import { processSignal } from '../../functions/custom_funnel-signal'
|
|
37
3
|
|
|
38
4
|
export const handler = createHandler(async (ctx, body) => {
|
|
39
|
-
const {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
5
|
+
const {
|
|
6
|
+
action_type,
|
|
7
|
+
action_value,
|
|
8
|
+
action_description,
|
|
9
|
+
session_id,
|
|
10
|
+
url,
|
|
11
|
+
path,
|
|
12
|
+
referrer,
|
|
13
|
+
user_agent,
|
|
14
|
+
anonymous_id
|
|
15
|
+
} = body || {}
|
|
16
|
+
|
|
17
|
+
if (!action_type || typeof action_value !== 'number') {
|
|
18
|
+
const err: any = new Error('action_type and action_value are required')
|
|
43
19
|
err.statusCode = 400
|
|
44
20
|
throw err
|
|
45
21
|
}
|
|
@@ -50,92 +26,30 @@ export const handler = createHandler(async (ctx, body) => {
|
|
|
50
26
|
throw err
|
|
51
27
|
}
|
|
52
28
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
},
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
},
|
|
69
|
-
action: {
|
|
70
|
-
action_type,
|
|
71
|
-
action_value,
|
|
72
|
-
action_description: action_description || null,
|
|
73
|
-
},
|
|
74
|
-
scoring_components: {
|
|
75
|
-
raw_score: {
|
|
76
|
-
calculated: scoring.calculated,
|
|
77
|
-
max_possible: 25,
|
|
78
|
-
rating: scoring.rating,
|
|
79
|
-
},
|
|
80
|
-
},
|
|
81
|
-
processing: {
|
|
82
|
-
received_at: now,
|
|
83
|
-
enriched_at: now,
|
|
84
|
-
scored_at: now,
|
|
85
|
-
stitched_at: null,
|
|
86
|
-
stitched_to_account_id: null,
|
|
87
|
-
},
|
|
29
|
+
const payload = {
|
|
30
|
+
account_id: ctx.accountId,
|
|
31
|
+
person_id: ctx.principal.id,
|
|
32
|
+
session_id: session_id || `portal_${ctx.principal.id}_${Date.now()}`,
|
|
33
|
+
anonymous_id: anonymous_id || null,
|
|
34
|
+
stage: 'identified',
|
|
35
|
+
source: 'port', // Portal-specific source for user engagement tracking
|
|
36
|
+
action_type,
|
|
37
|
+
action_value,
|
|
38
|
+
...(action_description && { action_description }),
|
|
39
|
+
...(url && { url }),
|
|
40
|
+
...(path && { path }),
|
|
41
|
+
...(referrer && { referrer }),
|
|
42
|
+
...(user_agent && { user_agent }),
|
|
43
|
+
occurred_at: new Date().toISOString()
|
|
88
44
|
}
|
|
89
45
|
|
|
90
|
-
const {
|
|
91
|
-
.from('items')
|
|
92
|
-
.insert({
|
|
93
|
-
type_id: ids.FUNNEL_SIGNAL_TYPE_ID,
|
|
94
|
-
title: `${action_type} - ${action_value}`,
|
|
95
|
-
account_id: ctx.accountId,
|
|
96
|
-
data: signalData,
|
|
97
|
-
})
|
|
98
|
-
.select('id')
|
|
99
|
-
.single()
|
|
46
|
+
const result = await processSignal(payload, { accountId: ctx.accountId, requestId: ctx.requestId }, {})
|
|
100
47
|
|
|
101
|
-
if (error) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
// Update account funnel data
|
|
106
|
-
const { data: account } = await adminDb
|
|
107
|
-
.from('accounts')
|
|
108
|
-
.select('data')
|
|
109
|
-
.eq('id', ctx.accountId)
|
|
110
|
-
.single()
|
|
111
|
-
|
|
112
|
-
if (account) {
|
|
113
|
-
const currentRating = account.data?.ratings?.identified?.rating || 0
|
|
114
|
-
const shouldUpdate = scoring.rating > currentRating
|
|
115
|
-
|
|
116
|
-
await adminDb
|
|
117
|
-
.from('accounts')
|
|
118
|
-
.update({
|
|
119
|
-
data: {
|
|
120
|
-
...account.data,
|
|
121
|
-
...(shouldUpdate && {
|
|
122
|
-
lead_score: scoring.calculated,
|
|
123
|
-
temperature: ratingToTemperature(scoring.rating),
|
|
124
|
-
lifecycle_stage: 'identified',
|
|
125
|
-
ratings: {
|
|
126
|
-
...(account.data?.ratings || {}),
|
|
127
|
-
identified: {
|
|
128
|
-
rating: scoring.rating,
|
|
129
|
-
raw_score: scoring.calculated,
|
|
130
|
-
calculated_at: now,
|
|
131
|
-
},
|
|
132
|
-
},
|
|
133
|
-
}),
|
|
134
|
-
last_signal_at: now,
|
|
135
|
-
},
|
|
136
|
-
})
|
|
137
|
-
.eq('id', ctx.accountId)
|
|
48
|
+
if (result?.status === 'error') {
|
|
49
|
+
const err: any = new Error(result.error || 'Signal processing failed')
|
|
50
|
+
err.statusCode = 400
|
|
51
|
+
throw err
|
|
138
52
|
}
|
|
139
53
|
|
|
140
|
-
return { status: 'ok', signal_id:
|
|
54
|
+
return { status: 'ok', signal_id: result?.signal_id }
|
|
141
55
|
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { apiFetch } from '@core/lib/api'
|
|
3
|
+
import { getTypeIdAsync } from './useTypeRegistry'
|
|
4
|
+
|
|
5
|
+
export interface CommunityPost {
|
|
6
|
+
id: string
|
|
7
|
+
title: string
|
|
8
|
+
description?: string
|
|
9
|
+
status: string
|
|
10
|
+
created_at: string
|
|
11
|
+
data?: Record<string, any>
|
|
12
|
+
design_schema?: Record<string, any>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const BASE = '/.netlify/functions/admin-data?entity=items&type_slug=community_post'
|
|
16
|
+
|
|
17
|
+
async function fetchJSON(path: string, options?: RequestInit) {
|
|
18
|
+
const res = await apiFetch(path, {
|
|
19
|
+
...options,
|
|
20
|
+
headers: { 'Content-Type': 'application/json', ...(options?.headers || {}) },
|
|
21
|
+
})
|
|
22
|
+
const text = await res.text()
|
|
23
|
+
let json: any
|
|
24
|
+
try { json = JSON.parse(text) } catch {
|
|
25
|
+
throw new Error(`HTTP ${res.status}: ${text.slice(0, 120)}`)
|
|
26
|
+
}
|
|
27
|
+
if (!res.ok || json.error) throw new Error(json.error || `HTTP ${res.status}`)
|
|
28
|
+
return json.data
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useCommunityPosts() {
|
|
32
|
+
const [posts, setPosts] = useState<CommunityPost[]>([])
|
|
33
|
+
const [loading, setLoading] = useState(true)
|
|
34
|
+
const [error, setError] = useState<string | null>(null)
|
|
35
|
+
|
|
36
|
+
const load = useCallback(async () => {
|
|
37
|
+
setLoading(true)
|
|
38
|
+
setError(null)
|
|
39
|
+
try {
|
|
40
|
+
const data = await fetchJSON(BASE)
|
|
41
|
+
setPosts(data || [])
|
|
42
|
+
} catch (e: any) {
|
|
43
|
+
setError(e.message)
|
|
44
|
+
} finally {
|
|
45
|
+
setLoading(false)
|
|
46
|
+
}
|
|
47
|
+
}, [])
|
|
48
|
+
|
|
49
|
+
useEffect(() => { load() }, [load])
|
|
50
|
+
|
|
51
|
+
return { posts, loading, error, refetch: load }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useCreatePost() {
|
|
55
|
+
const [loading, setLoading] = useState(false)
|
|
56
|
+
const [error, setError] = useState<string | null>(null)
|
|
57
|
+
|
|
58
|
+
const createPost = useCallback(async (fields: { title: string; description?: string; data?: Record<string, any> }) => {
|
|
59
|
+
setLoading(true)
|
|
60
|
+
setError(null)
|
|
61
|
+
try {
|
|
62
|
+
const typeId = await getTypeIdAsync('community_post')
|
|
63
|
+
if (!typeId) throw new Error('community_post type not found')
|
|
64
|
+
|
|
65
|
+
return await fetchJSON('/.netlify/functions/admin-data', {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
body: JSON.stringify({ entity: 'items', type_id: typeId, ...fields }),
|
|
68
|
+
})
|
|
69
|
+
} catch (e: any) {
|
|
70
|
+
setError(e.message)
|
|
71
|
+
throw e
|
|
72
|
+
} finally {
|
|
73
|
+
setLoading(false)
|
|
74
|
+
}
|
|
75
|
+
}, [])
|
|
76
|
+
|
|
77
|
+
return { createPost, loading, error }
|
|
78
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { apiFetch } from '@core/lib/api'
|
|
3
|
+
|
|
4
|
+
export interface CourseItem {
|
|
5
|
+
id: string
|
|
6
|
+
title: string
|
|
7
|
+
description?: string
|
|
8
|
+
status: string
|
|
9
|
+
created_at: string
|
|
10
|
+
data?: Record<string, any>
|
|
11
|
+
design_schema?: Record<string, any>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function fetchJSON(path: string, options?: RequestInit) {
|
|
15
|
+
const res = await apiFetch(path, {
|
|
16
|
+
...options,
|
|
17
|
+
headers: { 'Content-Type': 'application/json', ...(options?.headers || {}) },
|
|
18
|
+
})
|
|
19
|
+
const text = await res.text()
|
|
20
|
+
let json: any
|
|
21
|
+
try { json = JSON.parse(text) } catch {
|
|
22
|
+
throw new Error(`HTTP ${res.status}: ${text.slice(0, 120)}`)
|
|
23
|
+
}
|
|
24
|
+
if (!res.ok || json.error) throw new Error(json.error || `HTTP ${res.status}`)
|
|
25
|
+
return json.data
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useCourseLessons() {
|
|
29
|
+
const [lessons, setLessons] = useState<CourseItem[]>([])
|
|
30
|
+
const [loading, setLoading] = useState(true)
|
|
31
|
+
const [error, setError] = useState<string | null>(null)
|
|
32
|
+
|
|
33
|
+
const load = useCallback(async () => {
|
|
34
|
+
setLoading(true)
|
|
35
|
+
setError(null)
|
|
36
|
+
try {
|
|
37
|
+
const data = await fetchJSON('/.netlify/functions/admin-data?entity=items&type_slug=course_lesson')
|
|
38
|
+
setLessons(data || [])
|
|
39
|
+
} catch (e: any) {
|
|
40
|
+
setError(e.message)
|
|
41
|
+
} finally {
|
|
42
|
+
setLoading(false)
|
|
43
|
+
}
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
useEffect(() => { load() }, [load])
|
|
47
|
+
|
|
48
|
+
return { lessons, loading, error, refetch: load }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useCompleteLesson() {
|
|
52
|
+
const [loading, setLoading] = useState(false)
|
|
53
|
+
|
|
54
|
+
const completeLesson = useCallback(async (lessonId: string) => {
|
|
55
|
+
setLoading(true)
|
|
56
|
+
try {
|
|
57
|
+
return await fetchJSON(`/.netlify/functions/admin-data?entity=items&id=${lessonId}`, {
|
|
58
|
+
method: 'PATCH',
|
|
59
|
+
body: JSON.stringify({ status: 'completed' }),
|
|
60
|
+
})
|
|
61
|
+
} finally {
|
|
62
|
+
setLoading(false)
|
|
63
|
+
}
|
|
64
|
+
}, [])
|
|
65
|
+
|
|
66
|
+
return { completeLesson, loading }
|
|
67
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
2
|
+
import { apiFetch } from '@core/lib/api'
|
|
3
|
+
import { ItemProgress } from '@core/types/types'
|
|
4
|
+
|
|
5
|
+
async function fetchJSON(path: string, options?: RequestInit): Promise<any> {
|
|
6
|
+
const res = await apiFetch(path, {
|
|
7
|
+
...options,
|
|
8
|
+
headers: { 'Content-Type': 'application/json', ...(options?.headers || {}) },
|
|
9
|
+
})
|
|
10
|
+
const text = await res.text()
|
|
11
|
+
let json: any
|
|
12
|
+
try { json = JSON.parse(text) } catch {
|
|
13
|
+
throw new Error(`HTTP ${res.status}: ${text.slice(0, 120)}`)
|
|
14
|
+
}
|
|
15
|
+
if (!res.ok || json.error) throw new Error(json.error || `HTTP ${res.status}`)
|
|
16
|
+
return json.data ?? json
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Batch-fetches item_progress records for a set of item IDs for the current person.
|
|
21
|
+
* Returns a Map<itemId, ItemProgress> for O(1) lookup in components.
|
|
22
|
+
*
|
|
23
|
+
* @param personId - The current portal user's person ID
|
|
24
|
+
* @param itemIds - Array of item IDs to fetch progress for
|
|
25
|
+
*/
|
|
26
|
+
export function useItemProgress(personId: string | null, itemIds: string[]) {
|
|
27
|
+
const [progressMap, setProgressMap] = useState<Map<string, ItemProgress>>(new Map())
|
|
28
|
+
const [loading, setLoading] = useState(false)
|
|
29
|
+
const [error, setError] = useState<string | null>(null)
|
|
30
|
+
const prevKey = useRef<string>('')
|
|
31
|
+
|
|
32
|
+
const load = useCallback(async () => {
|
|
33
|
+
if (!personId || itemIds.length === 0) {
|
|
34
|
+
setProgressMap(new Map())
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const key = `${personId}:${itemIds.sort().join(',')}`
|
|
39
|
+
if (key === prevKey.current) return
|
|
40
|
+
prevKey.current = key
|
|
41
|
+
|
|
42
|
+
setLoading(true)
|
|
43
|
+
setError(null)
|
|
44
|
+
try {
|
|
45
|
+
const ids = itemIds.join(',')
|
|
46
|
+
const records: ItemProgress[] = await fetchJSON(
|
|
47
|
+
`/.netlify/functions/item-progress?person_id=${personId}&item_ids=${ids}`
|
|
48
|
+
)
|
|
49
|
+
const map = new Map<string, ItemProgress>()
|
|
50
|
+
for (const r of records || []) map.set(r.item_id, r)
|
|
51
|
+
setProgressMap(map)
|
|
52
|
+
} catch (e: any) {
|
|
53
|
+
setError(e.message)
|
|
54
|
+
} finally {
|
|
55
|
+
setLoading(false)
|
|
56
|
+
}
|
|
57
|
+
}, [personId, itemIds.join(',')])
|
|
58
|
+
|
|
59
|
+
useEffect(() => { load() }, [load])
|
|
60
|
+
|
|
61
|
+
return { progressMap, loading, error, refetch: load }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Returns an `upsert` function that creates or updates an item_progress record.
|
|
66
|
+
* Handles auto-composition of title/description server-side.
|
|
67
|
+
*
|
|
68
|
+
* Usage:
|
|
69
|
+
* const { upsert, loading } = useUpsertProgress()
|
|
70
|
+
* await upsert({ personId, itemId, typeId, accountId, status: 'completed', score: 85 })
|
|
71
|
+
*/
|
|
72
|
+
export function useUpsertProgress() {
|
|
73
|
+
const [loading, setLoading] = useState(false)
|
|
74
|
+
const [error, setError] = useState<string | null>(null)
|
|
75
|
+
|
|
76
|
+
const upsert = useCallback(async (params: {
|
|
77
|
+
personId: string
|
|
78
|
+
itemId: string
|
|
79
|
+
typeId: string
|
|
80
|
+
accountId: string
|
|
81
|
+
appId?: string
|
|
82
|
+
status?: string
|
|
83
|
+
score?: number
|
|
84
|
+
data?: Record<string, any>
|
|
85
|
+
force?: boolean
|
|
86
|
+
}): Promise<ItemProgress> => {
|
|
87
|
+
setLoading(true)
|
|
88
|
+
setError(null)
|
|
89
|
+
try {
|
|
90
|
+
const result = await fetchJSON('/.netlify/functions/item-progress', {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
person_id: params.personId,
|
|
94
|
+
item_id: params.itemId,
|
|
95
|
+
type_id: params.typeId,
|
|
96
|
+
account_id: params.accountId,
|
|
97
|
+
app_id: params.appId,
|
|
98
|
+
status: params.status,
|
|
99
|
+
score: params.score,
|
|
100
|
+
data: params.data,
|
|
101
|
+
force: params.force,
|
|
102
|
+
}),
|
|
103
|
+
})
|
|
104
|
+
return result
|
|
105
|
+
} catch (e: any) {
|
|
106
|
+
setError(e.message)
|
|
107
|
+
throw e
|
|
108
|
+
} finally {
|
|
109
|
+
setLoading(false)
|
|
110
|
+
}
|
|
111
|
+
}, [])
|
|
112
|
+
|
|
113
|
+
return { upsert, loading, error }
|
|
114
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { apiFetch } from '@core/lib/api'
|
|
3
|
+
|
|
4
|
+
export interface KBArticle {
|
|
5
|
+
id: string
|
|
6
|
+
title: string
|
|
7
|
+
description?: string
|
|
8
|
+
status: string
|
|
9
|
+
created_at: string
|
|
10
|
+
data?: Record<string, any>
|
|
11
|
+
design_schema?: Record<string, any>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function fetchJSON(path: string, options?: RequestInit) {
|
|
15
|
+
const res = await apiFetch(path, {
|
|
16
|
+
...options,
|
|
17
|
+
headers: { 'Content-Type': 'application/json', ...(options?.headers || {}) },
|
|
18
|
+
})
|
|
19
|
+
const text = await res.text()
|
|
20
|
+
let json: any
|
|
21
|
+
try { json = JSON.parse(text) } catch {
|
|
22
|
+
throw new Error(`HTTP ${res.status}: ${text.slice(0, 120)}`)
|
|
23
|
+
}
|
|
24
|
+
if (!res.ok || json.error) throw new Error(json.error || `HTTP ${res.status}`)
|
|
25
|
+
return json.data
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useKBArticles(search = '') {
|
|
29
|
+
const [articles, setArticles] = useState<KBArticle[]>([])
|
|
30
|
+
const [loading, setLoading] = useState(true)
|
|
31
|
+
const [error, setError] = useState<string | null>(null)
|
|
32
|
+
|
|
33
|
+
const load = useCallback(async () => {
|
|
34
|
+
setLoading(true)
|
|
35
|
+
setError(null)
|
|
36
|
+
try {
|
|
37
|
+
if (search && search.trim().length >= 2) {
|
|
38
|
+
// Vector similarity search via embeddings
|
|
39
|
+
const res = await apiFetch('/api/custom_kb-embeddings?action=search', {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { 'Content-Type': 'application/json' },
|
|
42
|
+
body: JSON.stringify({ query: search.trim(), limit: 10 }),
|
|
43
|
+
})
|
|
44
|
+
const json = await res.json()
|
|
45
|
+
const results = json.data || json || []
|
|
46
|
+
setArticles(Array.isArray(results) ? results : [])
|
|
47
|
+
} else {
|
|
48
|
+
// No search — show all published articles
|
|
49
|
+
const data = await fetchJSON(`/.netlify/functions/admin-data?entity=items&type_slug=kb_article&status=published`)
|
|
50
|
+
const visible = (data || []).filter((a: KBArticle) => a.data?.security_level !== 'restricted')
|
|
51
|
+
setArticles(visible)
|
|
52
|
+
}
|
|
53
|
+
} catch (e: any) {
|
|
54
|
+
setError(e.message)
|
|
55
|
+
} finally {
|
|
56
|
+
setLoading(false)
|
|
57
|
+
}
|
|
58
|
+
}, [search])
|
|
59
|
+
|
|
60
|
+
useEffect(() => { load() }, [load])
|
|
61
|
+
|
|
62
|
+
return { articles, loading, error, refetch: load }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useKBArticle(id: string | null) {
|
|
66
|
+
const [article, setArticle] = useState<KBArticle | null>(null)
|
|
67
|
+
const [loading, setLoading] = useState(false)
|
|
68
|
+
const [error, setError] = useState<string | null>(null)
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!id) { setArticle(null); return }
|
|
72
|
+
setLoading(true)
|
|
73
|
+
setError(null)
|
|
74
|
+
fetchJSON(`/.netlify/functions/admin-data?entity=items&id=${id}`)
|
|
75
|
+
.then(setArticle)
|
|
76
|
+
.catch((e: any) => setError(e.message))
|
|
77
|
+
.finally(() => setLoading(false))
|
|
78
|
+
}, [id])
|
|
79
|
+
|
|
80
|
+
return { article, loading, error }
|
|
81
|
+
}
|