spine-framework-portal 0.1.9 → 0.2.0

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.
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react'
2
- import { resolveTypeId } from '../../lib/resolveTypeId'
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 resolveTypeId('kb_article')
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
- // Portal Community Escalation Handler
2
- // Converts unanswered community posts to support tickets.
3
- // Standalone: no dependency on cortex functions.
4
- // Triggered by cron — see portal seed/triggers.json.
5
-
6
- import { createHandler } from './_shared/middleware'
7
- import { adminDb } from './_shared/db'
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
- // Attempt to trigger support triage pipeline if present
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(`Escalated post ${post.id} to ticket ${ticketId}`)
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
- // Portal Signal Handler
2
- // Records portal user actions as funnel signals in the items table.
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 { action_type, action_value, action_description, session_id } = body || {}
40
-
41
- if (!action_type || ![1, 2, 5].includes(action_value)) {
42
- const err: any = new Error('action_type and action_value (1, 2, or 5) are required')
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 ids = await resolveIds()
54
- const now = new Date().toISOString()
55
- const scoring = calculateSimpleScore(action_value)
56
- const resolvedSessionId = session_id || `portal_${ctx.principal.id}_${Date.now()}`
57
-
58
- const signalData = {
59
- identity: {
60
- anonymous_id: null,
61
- person_id: ctx.principal.id,
62
- account_id: ctx.accountId,
63
- session_id: resolvedSessionId,
64
- },
65
- classification: {
66
- stage: 'identified',
67
- source: 'int',
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 { data, error } = await adminDb
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
- throw new Error(`Failed to record portal signal: ${error.message}`)
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: data.id, rating: scoring.rating }
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
+ }