spine-framework-portal 0.1.1 → 0.1.2

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.
@@ -0,0 +1,202 @@
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'
8
+
9
+ interface CommunityPost {
10
+ id: string
11
+ title: string
12
+ description?: string
13
+ account_id: string
14
+ person_id: string
15
+ created_at: string
16
+ data?: {
17
+ category?: string
18
+ tags?: string[]
19
+ status?: string
20
+ escalation?: {
21
+ escalated_to_ticket_id?: string
22
+ }
23
+ }
24
+ }
25
+
26
+ async function escalatePostToTicket(post: CommunityPost): Promise<string | null> {
27
+ try {
28
+ const { data: existingTicket } = await adminDb
29
+ .from('items')
30
+ .select('id')
31
+ .eq('type_slug', 'support_ticket')
32
+ .eq('data->>source_post_id', post.id)
33
+ .limit(1)
34
+ .maybeSingle()
35
+
36
+ if (existingTicket) {
37
+ console.log(`Post ${post.id} already escalated to ticket ${existingTicket.id}`)
38
+ return null
39
+ }
40
+
41
+ const { data: newTicket, error: insertError } = await adminDb
42
+ .from('items')
43
+ .insert({
44
+ type_slug: 'support_ticket',
45
+ title: `Escalated: ${post.title}`,
46
+ description: post.description || 'No description provided',
47
+ account_id: post.account_id,
48
+ person_id: post.person_id,
49
+ status: 'open',
50
+ data: {
51
+ source_post_id: post.id,
52
+ source: 'community_escalation',
53
+ escalated_at: new Date().toISOString(),
54
+ original_category: post.data?.category || 'general',
55
+ original_tags: post.data?.tags || [],
56
+ community_status: 'unanswered_24h',
57
+ ai_metadata: {
58
+ confidence_threshold: 0.75,
59
+ escalation_reason: 'community_unanswered',
60
+ problem_statement: post.title,
61
+ source_content: post.description?.slice(0, 1000),
62
+ },
63
+ },
64
+ })
65
+ .select('id')
66
+ .single()
67
+
68
+ if (insertError || !newTicket) {
69
+ throw new Error(`Failed to create ticket: ${insertError?.message}`)
70
+ }
71
+
72
+ const ticketId = newTicket.id
73
+
74
+ await adminDb.from('threads').insert({
75
+ target_type: 'items',
76
+ target_id: ticketId,
77
+ visibility: 'external',
78
+ status: 'active',
79
+ })
80
+
81
+ await adminDb
82
+ .from('items')
83
+ .update({
84
+ data: {
85
+ ...post.data,
86
+ status: 'escalated',
87
+ escalation: {
88
+ escalated_to_ticket_id: ticketId,
89
+ escalated_at: new Date().toISOString(),
90
+ reason: 'unanswered_24h',
91
+ },
92
+ },
93
+ updated_at: new Date().toISOString(),
94
+ })
95
+ .eq('id', post.id)
96
+
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
+ }
124
+
125
+ console.log(`Escalated post ${post.id} to ticket ${ticketId}`)
126
+ return ticketId
127
+
128
+ } catch (err) {
129
+ console.error(`Failed to escalate post ${post.id}:`, err)
130
+ throw err
131
+ }
132
+ }
133
+
134
+ export const checkUnanswered = createHandler(async (_ctx, _body) => {
135
+ console.log('[PortalEscalation] Starting community escalation check...')
136
+
137
+ try {
138
+ const cutoffTime = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
139
+
140
+ const { data: unansweredPosts, error: postsError } = await adminDb
141
+ .from('items')
142
+ .select('id, title, description, account_id, person_id, created_at, data')
143
+ .eq('type_slug', 'community_post')
144
+ .not('data->>status', 'eq', 'escalated')
145
+ .lt('created_at', cutoffTime)
146
+ .order('created_at', { ascending: true })
147
+ .limit(50)
148
+
149
+ if (postsError) {
150
+ throw new Error(`Failed to fetch posts: ${postsError.message}`)
151
+ }
152
+
153
+ if (!unansweredPosts || unansweredPosts.length === 0) {
154
+ return { status: 'ok', processed: 0, escalated: 0, failed: 0, skipped: 0 }
155
+ }
156
+
157
+ const postsToEscalate: CommunityPost[] = []
158
+ for (const post of unansweredPosts) {
159
+ const { data: replies } = await adminDb
160
+ .from('items')
161
+ .select('id')
162
+ .eq('type_slug', 'community_reply')
163
+ .eq('data->>post_id', post.id)
164
+ .gt('created_at', post.created_at)
165
+ .limit(1)
166
+
167
+ if (!replies || replies.length === 0) {
168
+ postsToEscalate.push(post as CommunityPost)
169
+ }
170
+ }
171
+
172
+ console.log(`[PortalEscalation] Found ${postsToEscalate.length} unanswered posts`)
173
+
174
+ const results = { escalated: [] as string[], failed: [] as string[], skipped: [] as string[] }
175
+
176
+ for (const post of postsToEscalate) {
177
+ try {
178
+ const ticketId = await escalatePostToTicket(post)
179
+ if (ticketId) results.escalated.push(post.id)
180
+ else results.skipped.push(post.id)
181
+ } catch (err) {
182
+ console.error(`Failed to escalate post ${post.id}:`, err)
183
+ results.failed.push(post.id)
184
+ }
185
+ }
186
+
187
+ return {
188
+ status: 'ok',
189
+ processed: postsToEscalate.length,
190
+ escalated: results.escalated.length,
191
+ failed: results.failed.length,
192
+ skipped: results.skipped.length,
193
+ details: results,
194
+ }
195
+
196
+ } catch (err) {
197
+ console.error('[PortalEscalation] Failed:', err)
198
+ const error: any = new Error('Failed to process community escalation')
199
+ error.statusCode = 500
200
+ throw error
201
+ }
202
+ })
@@ -1,11 +1,45 @@
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
+
1
6
  import { createHandler } from './_shared/middleware'
2
- import { processSignal } from './custom_funnel-signal'
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
+ }
3
37
 
4
38
  export const handler = createHandler(async (ctx, body) => {
5
39
  const { action_type, action_value, action_description, session_id } = body || {}
6
40
 
7
- if (!action_type || typeof action_value !== 'number') {
8
- const err: any = new Error('action_type and action_value are required')
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')
9
43
  err.statusCode = 400
10
44
  throw err
11
45
  }
@@ -16,18 +50,92 @@ export const handler = createHandler(async (ctx, body) => {
16
50
  throw err
17
51
  }
18
52
 
19
- const payload = {
20
- account_id: ctx.accountId,
21
- person_id: ctx.principal.id,
22
- session_id: session_id || `portal_${ctx.principal.id}_${Date.now()}`,
23
- stage: 'identified',
24
- source: 'int',
25
- action_type,
26
- action_value,
27
- ...(action_description && { action_description }),
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
+ },
88
+ }
89
+
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()
100
+
101
+ if (error) {
102
+ throw new Error(`Failed to record portal signal: ${error.message}`)
28
103
  }
29
104
 
30
- await processSignal(payload, { accountId: ctx.accountId, requestId: ctx.requestId }, {})
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)
138
+ }
31
139
 
32
- return { status: 'ok' }
140
+ return { status: 'ok', signal_id: data.id, rating: scoring.rating }
33
141
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework-portal",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Customer Portal — self-service portal app for Spine Framework",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,8 +10,7 @@
10
10
  "directory": "custom/apps/customer-portal"
11
11
  },
12
12
  "peerDependencies": {
13
- "spine-framework": ">=0.1.0",
14
- "spine-framework-cortex": ">=0.1.0"
13
+ "spine-framework": ">=0.1.0"
15
14
  },
16
15
  "files": [
17
16
  "index.tsx",
@@ -5,7 +5,7 @@
5
5
  "trigger_type": "cron",
6
6
  "event_type": null,
7
7
  "config": {
8
- "function": "custom_community-escalation.checkUnanswered",
8
+ "function": "custom_portal-community-escalation.checkUnanswered",
9
9
  "schedule": "0 */4 * * *",
10
10
  "timezone": "UTC",
11
11
  "description": "Check for community posts >24h without answers, create tickets"