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 {
|
|
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 ||
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
package/seed/triggers.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"trigger_type": "cron",
|
|
6
6
|
"event_type": null,
|
|
7
7
|
"config": {
|
|
8
|
-
"function": "
|
|
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"
|