spine-framework-cortex 0.1.19 → 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.
- package/{functions/custom_cortex-handler.ts → api/cortex-handler.ts} +9 -9
- package/components/CliInstancesCard.tsx +144 -0
- package/components/CortexSidebar.tsx +27 -53
- package/hooks/useTypeRegistry.ts +74 -0
- package/index.tsx +13 -24
- package/manifest.json +1 -13
- package/package.json +11 -20
- package/pages/courses/CoursesPage.tsx +14 -4
- package/pages/crm/AccountDetailPage.tsx +149 -194
- package/pages/crm/ContactsPage.tsx +7 -7
- package/pages/intelligence/IntelligencePage.tsx +24 -31
- package/pages/kb/KBEditorPage.tsx +9 -2
- package/pages/operations/AuditFunnelPage.tsx +378 -0
- package/pages/operations/InstallFunnelPage.tsx +410 -0
- package/pages/operations/OperationsDashboard.tsx +275 -0
- package/pages/support/RedactionReview.tsx +11 -2
- package/seed/link-types.json +8 -42
- package/seed/package.json +27 -0
- package/seed/roles.json +1 -1
- package/seed/types.json +2711 -596
- package/CHANGELOG.md +0 -46
- package/LICENSE.md +0 -223
- package/README.md +0 -69
- package/functions/custom_anonymous-sessions.ts +0 -356
- package/functions/custom_case_analysis.ts +0 -507
- package/functions/custom_community-escalation.ts +0 -234
- package/functions/custom_cortex-chunks.ts +0 -52
- package/functions/custom_funnel-scoring.ts +0 -256
- package/functions/custom_funnel-signal.ts +0 -446
- package/functions/custom_funnel-timers.ts +0 -449
- package/functions/custom_kb-chunker-test.ts +0 -364
- package/functions/custom_kb-chunker.ts +0 -576
- package/functions/custom_kb-embeddings.ts +0 -481
- package/functions/custom_kb-ingestion.ts +0 -448
- package/functions/custom_support-triage.ts +0 -649
- package/functions/custom_tag_management.ts +0 -314
- package/functions/webhook-handlers.ts +0 -29
- package/lib/resolveTypeId.ts +0 -16
- package/pages/crm/ContactDetailPage.tsx +0 -184
- package/pages/ops/AuditFunnelPage.tsx +0 -191
- package/pages/ops/CommandCenterPage.tsx +0 -377
- package/pages/ops/InstallFunnelPage.tsx +0 -226
- package/seed/accounts.json +0 -9
- package/seed/integrations.json +0 -24
- package/seed/pipelines.json +0 -59
- package/seed/triggers.json +0 -125
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
import { createHandler } from './_shared/middleware'
|
|
2
|
-
import { adminDb } from './_shared/db'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Custom 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
|
-
*/
|
|
12
|
-
|
|
13
|
-
interface CommunityPost {
|
|
14
|
-
id: string
|
|
15
|
-
title: string
|
|
16
|
-
description?: string
|
|
17
|
-
account_id: string
|
|
18
|
-
person_id: string
|
|
19
|
-
created_at: string
|
|
20
|
-
data?: {
|
|
21
|
-
category?: string
|
|
22
|
-
tags?: string[]
|
|
23
|
-
status?: string
|
|
24
|
-
escalation?: {
|
|
25
|
-
escalated_to_ticket_id?: string
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async function escalatePostToTicket(post: CommunityPost): Promise<string | null> {
|
|
31
|
-
try {
|
|
32
|
-
// Check if a ticket already exists for this post
|
|
33
|
-
const { data: existingTicket } = await adminDb
|
|
34
|
-
.from('items')
|
|
35
|
-
.select('id')
|
|
36
|
-
.eq('type_slug', 'support_ticket')
|
|
37
|
-
.eq('data->>source_post_id', post.id)
|
|
38
|
-
.limit(1)
|
|
39
|
-
.maybeSingle()
|
|
40
|
-
|
|
41
|
-
if (existingTicket) {
|
|
42
|
-
console.log(`Post ${post.id} already escalated to ticket ${existingTicket.id}`)
|
|
43
|
-
return null
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Create the support ticket
|
|
47
|
-
const ticketTitle = `Escalated: ${post.title}`
|
|
48
|
-
const ticketDescription = post.description || 'No description provided'
|
|
49
|
-
|
|
50
|
-
const { data: newTicket, error: insertError } = await adminDb
|
|
51
|
-
.from('items')
|
|
52
|
-
.insert({
|
|
53
|
-
type_slug: 'support_ticket',
|
|
54
|
-
title: ticketTitle,
|
|
55
|
-
description: ticketDescription,
|
|
56
|
-
account_id: post.account_id,
|
|
57
|
-
person_id: post.person_id,
|
|
58
|
-
status: 'open',
|
|
59
|
-
data: {
|
|
60
|
-
source_post_id: post.id,
|
|
61
|
-
source: 'community_escalation',
|
|
62
|
-
escalated_at: new Date().toISOString(),
|
|
63
|
-
original_category: post.data?.category || 'general',
|
|
64
|
-
original_tags: post.data?.tags || [],
|
|
65
|
-
community_status: 'unanswered_24h',
|
|
66
|
-
ai_metadata: {
|
|
67
|
-
confidence_threshold: 0.75,
|
|
68
|
-
escalation_reason: 'community_unanswered',
|
|
69
|
-
problem_statement: post.title,
|
|
70
|
-
source_content: post.description?.slice(0, 1000)
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
})
|
|
74
|
-
.select('id')
|
|
75
|
-
.single()
|
|
76
|
-
|
|
77
|
-
if (insertError || !newTicket) {
|
|
78
|
-
throw new Error(`Failed to create ticket: ${insertError?.message}`)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const ticketId = newTicket.id
|
|
82
|
-
|
|
83
|
-
// Create external thread for the ticket
|
|
84
|
-
await adminDb.from('threads').insert({
|
|
85
|
-
target_type: 'items',
|
|
86
|
-
target_id: ticketId,
|
|
87
|
-
visibility: 'external',
|
|
88
|
-
status: 'active'
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
// Update the community post to mark it as escalated
|
|
92
|
-
const updatedData = {
|
|
93
|
-
...post.data,
|
|
94
|
-
status: 'escalated',
|
|
95
|
-
escalation: {
|
|
96
|
-
escalated_to_ticket_id: ticketId,
|
|
97
|
-
escalated_at: new Date().toISOString(),
|
|
98
|
-
reason: 'unanswered_24h'
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
await adminDb
|
|
103
|
-
.from('items')
|
|
104
|
-
.update({ data: updatedData, updated_at: new Date().toISOString() })
|
|
105
|
-
.eq('id', post.id)
|
|
106
|
-
|
|
107
|
-
// Trigger AI triage agent on the new ticket
|
|
108
|
-
await triggerTriageAgent(ticketId, post.account_id, post.title, post.description)
|
|
109
|
-
|
|
110
|
-
console.log(`Successfully escalated post ${post.id} to ticket ${ticketId}`)
|
|
111
|
-
return ticketId
|
|
112
|
-
|
|
113
|
-
} catch (err) {
|
|
114
|
-
console.error(`Failed to escalate post ${post.id}:`, err)
|
|
115
|
-
throw err
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function triggerTriageAgent(
|
|
120
|
-
ticketId: string,
|
|
121
|
-
accountId: string,
|
|
122
|
-
title: string,
|
|
123
|
-
content?: string
|
|
124
|
-
): Promise<void> {
|
|
125
|
-
try {
|
|
126
|
-
// Find the support triage pipeline
|
|
127
|
-
const { data: pipeline } = await adminDb
|
|
128
|
-
.from('pipelines')
|
|
129
|
-
.select('id')
|
|
130
|
-
.ilike('name', '%support%triage%')
|
|
131
|
-
.limit(1)
|
|
132
|
-
.maybeSingle()
|
|
133
|
-
|
|
134
|
-
if (!pipeline) {
|
|
135
|
-
console.log('No support triage pipeline found, skipping auto-trigger')
|
|
136
|
-
return
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Create pipeline execution
|
|
140
|
-
await adminDb.from('pipeline_executions').insert({
|
|
141
|
-
pipeline_id: pipeline.id,
|
|
142
|
-
target_type: 'items',
|
|
143
|
-
target_id: ticketId,
|
|
144
|
-
status: 'pending',
|
|
145
|
-
input_context: {
|
|
146
|
-
ticket_id: ticketId,
|
|
147
|
-
account_id: accountId,
|
|
148
|
-
title: title,
|
|
149
|
-
description: content || '',
|
|
150
|
-
source: 'community_escalation'
|
|
151
|
-
}
|
|
152
|
-
})
|
|
153
|
-
} catch (err) {
|
|
154
|
-
console.error('Failed to trigger triage agent:', err)
|
|
155
|
-
// Non-fatal: ticket was created, triage can be run manually
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export const handler = createHandler(async (_ctx, _body) => {
|
|
160
|
-
console.log('Starting community escalation check...')
|
|
161
|
-
|
|
162
|
-
try {
|
|
163
|
-
const cutoffTime = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
|
|
164
|
-
|
|
165
|
-
// Find posts unanswered for 24+ hours
|
|
166
|
-
const { data: unansweredPosts, error: postsError } = await adminDb
|
|
167
|
-
.from('items')
|
|
168
|
-
.select('id, title, description, account_id, person_id, created_at, data')
|
|
169
|
-
.eq('type_slug', 'community_post')
|
|
170
|
-
.not('data->>status', 'eq', 'escalated')
|
|
171
|
-
.lt('created_at', cutoffTime)
|
|
172
|
-
.order('created_at', { ascending: true })
|
|
173
|
-
.limit(50)
|
|
174
|
-
|
|
175
|
-
if (postsError) {
|
|
176
|
-
throw new Error(`Failed to fetch posts: ${postsError.message}`)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (!unansweredPosts || unansweredPosts.length === 0) {
|
|
180
|
-
return { status: 'ok', processed: 0, escalated: 0, failed: 0, skipped: 0 }
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Filter out posts that have replies
|
|
184
|
-
const postsToEscalate: CommunityPost[] = []
|
|
185
|
-
for (const post of unansweredPosts) {
|
|
186
|
-
const { data: replies } = await adminDb
|
|
187
|
-
.from('items')
|
|
188
|
-
.select('id')
|
|
189
|
-
.eq('type_slug', 'community_reply')
|
|
190
|
-
.eq('data->>post_id', post.id)
|
|
191
|
-
.gt('created_at', post.created_at)
|
|
192
|
-
.limit(1)
|
|
193
|
-
|
|
194
|
-
if (!replies || replies.length === 0) {
|
|
195
|
-
postsToEscalate.push(post as CommunityPost)
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
console.log(`Found ${postsToEscalate.length} unanswered posts to escalate`)
|
|
200
|
-
|
|
201
|
-
const results = { escalated: [] as string[], failed: [] as string[], skipped: [] as string[] }
|
|
202
|
-
|
|
203
|
-
for (const post of postsToEscalate) {
|
|
204
|
-
try {
|
|
205
|
-
const ticketId = await escalatePostToTicket(post)
|
|
206
|
-
if (ticketId) {
|
|
207
|
-
results.escalated.push(post.id)
|
|
208
|
-
} else {
|
|
209
|
-
results.skipped.push(post.id)
|
|
210
|
-
}
|
|
211
|
-
} catch (err) {
|
|
212
|
-
console.error(`Failed to escalate post ${post.id}:`, err)
|
|
213
|
-
results.failed.push(post.id)
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
console.log('Escalation complete:', results)
|
|
218
|
-
|
|
219
|
-
return {
|
|
220
|
-
status: 'ok',
|
|
221
|
-
processed: postsToEscalate.length,
|
|
222
|
-
escalated: results.escalated.length,
|
|
223
|
-
failed: results.failed.length,
|
|
224
|
-
skipped: results.skipped.length,
|
|
225
|
-
details: results
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
} catch (err) {
|
|
229
|
-
console.error('Community escalation failed:', err)
|
|
230
|
-
const error: any = new Error('Failed to process community escalation')
|
|
231
|
-
error.statusCode = 500
|
|
232
|
-
throw error
|
|
233
|
-
}
|
|
234
|
-
})
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { createHandler } from './_shared/middleware'
|
|
2
|
-
import { adminDb } from './_shared/db'
|
|
3
|
-
|
|
4
|
-
export const handler = createHandler(async (ctx, body) => {
|
|
5
|
-
const { action } = ctx.query || {}
|
|
6
|
-
const method = ctx.query?.method || 'GET'
|
|
7
|
-
|
|
8
|
-
switch (action) {
|
|
9
|
-
case 'list':
|
|
10
|
-
if (method === 'GET') {
|
|
11
|
-
try {
|
|
12
|
-
// Read chunks from the project root directory
|
|
13
|
-
const fs = require('fs')
|
|
14
|
-
const path = require('path')
|
|
15
|
-
const chunksPath = path.join(process.cwd(), 'chunks.json')
|
|
16
|
-
const fileContent = fs.readFileSync(chunksPath, 'utf8')
|
|
17
|
-
const data = JSON.parse(fileContent)
|
|
18
|
-
|
|
19
|
-
return {
|
|
20
|
-
chunks: data.chunks || [],
|
|
21
|
-
total: data.chunks?.length || 0,
|
|
22
|
-
loaded_at: new Date().toISOString()
|
|
23
|
-
}
|
|
24
|
-
} catch (error) {
|
|
25
|
-
throw new Error(`Failed to load chunks: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
break
|
|
29
|
-
|
|
30
|
-
default:
|
|
31
|
-
if (method === 'GET') {
|
|
32
|
-
try {
|
|
33
|
-
// Read chunks from the project root directory
|
|
34
|
-
const fs = require('fs')
|
|
35
|
-
const path = require('path')
|
|
36
|
-
const chunksPath = path.join(process.cwd(), 'chunks.json')
|
|
37
|
-
const fileContent = fs.readFileSync(chunksPath, 'utf8')
|
|
38
|
-
const data = JSON.parse(fileContent)
|
|
39
|
-
|
|
40
|
-
return {
|
|
41
|
-
chunks: data.chunks || [],
|
|
42
|
-
total: data.chunks?.length || 0,
|
|
43
|
-
loaded_at: new Date().toISOString()
|
|
44
|
-
}
|
|
45
|
-
} catch (error) {
|
|
46
|
-
throw new Error(`Failed to load chunks: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
throw new Error('Invalid action or method')
|
|
52
|
-
})
|
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
// Funnel Scoring Engine
|
|
2
|
-
// Pure calculation utilities - NO database access
|
|
3
|
-
// All functions are deterministic and testable
|
|
4
|
-
|
|
5
|
-
// ============================================
|
|
6
|
-
// TYPES
|
|
7
|
-
// ============================================
|
|
8
|
-
|
|
9
|
-
export interface EngagementResult {
|
|
10
|
-
type: 1 | 2 | 5
|
|
11
|
-
context: 'first_visit' | 'deep_session' | 'return_visit'
|
|
12
|
-
session_depth: number
|
|
13
|
-
prior_session_count?: number
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface RecencyResult {
|
|
17
|
-
divisor: 1 | 2 | 5 | null
|
|
18
|
-
age_days: number
|
|
19
|
-
window: 'fresh' | 'cooling' | 'stale' | 'expired'
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface RawScoreResult {
|
|
23
|
-
calculated: number
|
|
24
|
-
max_possible: number
|
|
25
|
-
rating: 1 | 2 | 3 | 4 | 5
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface StageConfig {
|
|
29
|
-
max_lookback_days: number
|
|
30
|
-
fresh_days: number
|
|
31
|
-
cooling_days: number
|
|
32
|
-
stale_days: number
|
|
33
|
-
deep_engagement_action_count: number
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Stage configurations per plan
|
|
37
|
-
const STAGE_CONFIGS: Record<string, StageConfig> = {
|
|
38
|
-
anonymous: {
|
|
39
|
-
max_lookback_days: 90,
|
|
40
|
-
fresh_days: 7,
|
|
41
|
-
cooling_days: 30,
|
|
42
|
-
stale_days: 90,
|
|
43
|
-
deep_engagement_action_count: 4
|
|
44
|
-
},
|
|
45
|
-
identified: {
|
|
46
|
-
max_lookback_days: 120,
|
|
47
|
-
fresh_days: 14,
|
|
48
|
-
cooling_days: 45,
|
|
49
|
-
stale_days: 90,
|
|
50
|
-
deep_engagement_action_count: 3
|
|
51
|
-
},
|
|
52
|
-
installed: {
|
|
53
|
-
max_lookback_days: 90,
|
|
54
|
-
fresh_days: 7,
|
|
55
|
-
cooling_days: 21,
|
|
56
|
-
stale_days: 45,
|
|
57
|
-
deep_engagement_action_count: 3
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ============================================
|
|
62
|
-
// ENGAGEMENT CALCULATION
|
|
63
|
-
// ============================================
|
|
64
|
-
|
|
65
|
-
export function calculateEngagement(
|
|
66
|
-
priorSignals: Array<{ session_id: string; occurred_at: string }>,
|
|
67
|
-
currentSessionId: string,
|
|
68
|
-
currentOccurredAt: string,
|
|
69
|
-
stage: string
|
|
70
|
-
): EngagementResult {
|
|
71
|
-
const config = STAGE_CONFIGS[stage] || STAGE_CONFIGS.anonymous
|
|
72
|
-
|
|
73
|
-
// First visit - no prior signals
|
|
74
|
-
if (priorSignals.length === 0) {
|
|
75
|
-
return { type: 1, context: 'first_visit', session_depth: 1 }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Check for return visit
|
|
79
|
-
const lastSignal = priorSignals[priorSignals.length - 1]
|
|
80
|
-
const hoursSinceLast = differenceInHours(
|
|
81
|
-
new Date(currentOccurredAt),
|
|
82
|
-
new Date(lastSignal.occurred_at)
|
|
83
|
-
)
|
|
84
|
-
const isNewSession = currentSessionId !== lastSignal.session_id
|
|
85
|
-
|
|
86
|
-
if (isNewSession || hoursSinceLast >= 4) {
|
|
87
|
-
const uniqueSessions = new Set(priorSignals.map(s => s.session_id)).size
|
|
88
|
-
return {
|
|
89
|
-
type: 5,
|
|
90
|
-
context: 'return_visit',
|
|
91
|
-
session_depth: 1,
|
|
92
|
-
prior_session_count: uniqueSessions
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Same session - check depth
|
|
97
|
-
const sameSessionSignals = priorSignals.filter(s =>
|
|
98
|
-
s.session_id === currentSessionId &&
|
|
99
|
-
isSameDay(new Date(s.occurred_at), new Date(currentOccurredAt))
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
const sessionDepth = sameSessionSignals.length + 1
|
|
103
|
-
|
|
104
|
-
if (sessionDepth >= config.deep_engagement_action_count) {
|
|
105
|
-
return { type: 2, context: 'deep_session', session_depth: sessionDepth }
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return { type: 1, context: 'first_visit', session_depth: sessionDepth }
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// ============================================
|
|
112
|
-
// RECENCY CALCULATION
|
|
113
|
-
// ============================================
|
|
114
|
-
|
|
115
|
-
export function calculateRecency(
|
|
116
|
-
occurredAt: Date,
|
|
117
|
-
now: Date = new Date(),
|
|
118
|
-
stage: string = 'anonymous'
|
|
119
|
-
): RecencyResult {
|
|
120
|
-
const config = STAGE_CONFIGS[stage] || STAGE_CONFIGS.anonymous
|
|
121
|
-
const ageDays = differenceInDays(now, occurredAt)
|
|
122
|
-
|
|
123
|
-
if (ageDays > config.max_lookback_days) {
|
|
124
|
-
return { divisor: null, age_days: ageDays, window: 'expired' }
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (ageDays <= config.fresh_days) {
|
|
128
|
-
return { divisor: 1, age_days: ageDays, window: 'fresh' }
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (ageDays <= config.cooling_days) {
|
|
132
|
-
return { divisor: 2, age_days: ageDays, window: 'cooling' }
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return { divisor: 5, age_days: ageDays, window: 'stale' }
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// ============================================
|
|
139
|
-
// RAW SCORE CALCULATION
|
|
140
|
-
// ============================================
|
|
141
|
-
|
|
142
|
-
export function calculateRawScore(
|
|
143
|
-
actionValue: 1 | 2 | 5,
|
|
144
|
-
engagementType: 1 | 2 | 5,
|
|
145
|
-
recencyDivisor: 1 | 2 | 5
|
|
146
|
-
): RawScoreResult {
|
|
147
|
-
const calculated = (actionValue * engagementType) / recencyDivisor
|
|
148
|
-
|
|
149
|
-
let rating: 1 | 2 | 3 | 4 | 5
|
|
150
|
-
if (calculated <= 1) rating = 1
|
|
151
|
-
else if (calculated <= 4) rating = 2
|
|
152
|
-
else if (calculated <= 8) rating = 3
|
|
153
|
-
else if (calculated <= 15) rating = 4
|
|
154
|
-
else rating = 5
|
|
155
|
-
|
|
156
|
-
return {
|
|
157
|
-
calculated,
|
|
158
|
-
max_possible: 25, // 5 * 5 / 1
|
|
159
|
-
rating
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// ============================================
|
|
164
|
-
// BEST-SIGNAL-WINS CALCULATION
|
|
165
|
-
// ============================================
|
|
166
|
-
|
|
167
|
-
export function findBestSignal<T extends { scoring_components?: { raw_score?: { calculated?: number; rating?: number } } }>(
|
|
168
|
-
signals: T[]
|
|
169
|
-
): { signal: T | null; rating: number; raw_score: number } {
|
|
170
|
-
if (signals.length === 0) {
|
|
171
|
-
return { signal: null, rating: 0, raw_score: 0 }
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
let bestSignal = signals[0]
|
|
175
|
-
let bestScore = signals[0]?.scoring_components?.raw_score?.calculated || 0
|
|
176
|
-
|
|
177
|
-
for (const signal of signals) {
|
|
178
|
-
const score = signal?.scoring_components?.raw_score?.calculated || 0
|
|
179
|
-
if (score > bestScore) {
|
|
180
|
-
bestScore = score
|
|
181
|
-
bestSignal = signal
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
return {
|
|
186
|
-
signal: bestSignal,
|
|
187
|
-
rating: bestSignal?.scoring_components?.raw_score?.rating || 0,
|
|
188
|
-
raw_score: bestScore
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ============================================
|
|
193
|
-
// UTILITY FUNCTIONS
|
|
194
|
-
// ============================================
|
|
195
|
-
|
|
196
|
-
function differenceInHours(date1: Date, date2: Date): number {
|
|
197
|
-
const msPerHour = 1000 * 60 * 60
|
|
198
|
-
return Math.abs(date1.getTime() - date2.getTime()) / msPerHour
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function differenceInDays(date1: Date, date2: Date): number {
|
|
202
|
-
return Math.floor(differenceInHours(date1, date2) / 24)
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function isSameDay(date1: Date, date2: Date): boolean {
|
|
206
|
-
return date1.toDateString() === date2.toDateString()
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// ============================================
|
|
210
|
-
// REFERRER CATEGORIZATION
|
|
211
|
-
// ============================================
|
|
212
|
-
|
|
213
|
-
export function categorizeReferrer(referrerDomain: string): string {
|
|
214
|
-
const social = ['linkedin.com', 'twitter.com', 'x.com', 'facebook.com', 'instagram.com']
|
|
215
|
-
const search = ['google.com', 'bing.com', 'duckduckgo.com']
|
|
216
|
-
|
|
217
|
-
const domain = referrerDomain.toLowerCase()
|
|
218
|
-
|
|
219
|
-
if (social.some(s => domain.includes(s))) return 'social'
|
|
220
|
-
if (search.some(s => domain.includes(s))) return 'search'
|
|
221
|
-
if (!domain || domain === 'direct') return 'direct'
|
|
222
|
-
|
|
223
|
-
return 'referral'
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// ============================================
|
|
227
|
-
// OPPORTUNITY TYPE INFERENCE
|
|
228
|
-
// ============================================
|
|
229
|
-
|
|
230
|
-
export function inferOpportunityType(
|
|
231
|
-
signals: Array<{ action?: { action_type: string } }>,
|
|
232
|
-
stage: string,
|
|
233
|
-
rating: number
|
|
234
|
-
): { type: string; confidence: 'low' | 'medium' | 'high' } {
|
|
235
|
-
const actionTypes = signals.map(s => s.action?.action_type || '').join(' ')
|
|
236
|
-
|
|
237
|
-
// High-value signals indicate specific opportunities
|
|
238
|
-
if (actionTypes.includes('pricing') && rating >= 4) {
|
|
239
|
-
return { type: 'advanced_portal', confidence: 'high' }
|
|
240
|
-
}
|
|
241
|
-
if (actionTypes.includes('health_ping') && actionTypes.includes('production')) {
|
|
242
|
-
return { type: 'managed_services', confidence: 'high' }
|
|
243
|
-
}
|
|
244
|
-
if (actionTypes.includes('support_ticket') && rating >= 3) {
|
|
245
|
-
return { type: 'support_plan', confidence: 'medium' }
|
|
246
|
-
}
|
|
247
|
-
if (stage === 'installed' && rating >= 4) {
|
|
248
|
-
return { type: 'expansion', confidence: 'medium' }
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Default based on stage
|
|
252
|
-
if (stage === 'anonymous') return { type: 'implementation', confidence: 'low' }
|
|
253
|
-
if (stage === 'identified') return { type: 'advanced_portal', confidence: 'low' }
|
|
254
|
-
|
|
255
|
-
return { type: 'advocate', confidence: 'low' }
|
|
256
|
-
}
|