spine-framework-cortex 0.1.18 → 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 -42
- 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 -430
- 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,430 +0,0 @@
|
|
|
1
|
-
// Funnel Signal Handler
|
|
2
|
-
// Ingests, scores, and persists funnel signals end-to-end.
|
|
3
|
-
//
|
|
4
|
-
// Handler Signature (per integration-routes.ts):
|
|
5
|
-
// scriptHandler(sanitizedData, scriptContext, scriptEvent)
|
|
6
|
-
// - sanitizedData: request body (sanitized by integration-routes)
|
|
7
|
-
// - scriptContext: { integrationId, accountId, slug, principal, requestId, headers }
|
|
8
|
-
// - scriptEvent: { httpMethod, headers, body, path, queryStringParameters }
|
|
9
|
-
//
|
|
10
|
-
// Uses adminDb for type-resolution lookups and all CRUD writes.
|
|
11
|
-
// Scoring is inline — the Core trigger/pipeline engine cannot directly invoke
|
|
12
|
-
// a custom function, so scoring is done synchronously here.
|
|
13
|
-
|
|
14
|
-
import { adminDb } from './_shared/db'
|
|
15
|
-
import {
|
|
16
|
-
calculateEngagement,
|
|
17
|
-
calculateRecency,
|
|
18
|
-
calculateRawScore,
|
|
19
|
-
inferOpportunityType,
|
|
20
|
-
categorizeReferrer,
|
|
21
|
-
} from './custom_funnel-scoring'
|
|
22
|
-
|
|
23
|
-
// ============================================
|
|
24
|
-
// SIGNAL HANDLER (Integration Routes Compatible)
|
|
25
|
-
// ============================================
|
|
26
|
-
|
|
27
|
-
export default async function handler(
|
|
28
|
-
sanitizedData: any,
|
|
29
|
-
scriptContext: any,
|
|
30
|
-
_scriptEvent: any
|
|
31
|
-
) {
|
|
32
|
-
return processSignal(sanitizedData, scriptContext, _scriptEvent)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function processSignal(
|
|
36
|
-
sanitizedData: any,
|
|
37
|
-
scriptContext: any,
|
|
38
|
-
_scriptEvent: any
|
|
39
|
-
) {
|
|
40
|
-
const receivedAt = new Date().toISOString()
|
|
41
|
-
|
|
42
|
-
// 1. VALIDATE
|
|
43
|
-
const validation = validateSignalPayload(sanitizedData)
|
|
44
|
-
if (!validation.valid) {
|
|
45
|
-
return { status: 'error', error: (validation as { valid: false; error: string }).error }
|
|
46
|
-
}
|
|
47
|
-
const payload = validation.data
|
|
48
|
-
|
|
49
|
-
// 2. RESOLVE IDs (read-only type/account lookups)
|
|
50
|
-
const [signalTypeId, sessionTypeId, queueTypeId, signalLinkTypeId, opportunityLinkTypeId, unidentifiedAccountId] =
|
|
51
|
-
await Promise.all([
|
|
52
|
-
resolveTypeId('funnel_signal'),
|
|
53
|
-
resolveTypeId('anonymous_session'),
|
|
54
|
-
resolveTypeId('opportunity_queue'),
|
|
55
|
-
resolveLinkTypeId('account_signals'),
|
|
56
|
-
resolveLinkTypeId('account_opportunities'),
|
|
57
|
-
resolveUnidentifiedAccountId(),
|
|
58
|
-
])
|
|
59
|
-
|
|
60
|
-
if (!signalTypeId) {
|
|
61
|
-
return { status: 'error', error: 'funnel_signal type not found — run install-app cortex' }
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// 3. ENRICH — fetch prior signals for this identity
|
|
65
|
-
const priorSignals = await fetchPriorSignals(payload, signalTypeId)
|
|
66
|
-
|
|
67
|
-
// 4. SCORE
|
|
68
|
-
const occurredAt = payload.occurred_at ? new Date(payload.occurred_at) : new Date()
|
|
69
|
-
const referrerDomain = extractDomain(payload.referrer)
|
|
70
|
-
const referrerCategory = categorizeReferrer(referrerDomain)
|
|
71
|
-
const engagement = calculateEngagement(priorSignals, payload.session_id || 'default', occurredAt.toISOString(), payload.stage)
|
|
72
|
-
const recency = calculateRecency(occurredAt, new Date(), payload.stage)
|
|
73
|
-
const scoring = recency.divisor === null
|
|
74
|
-
? { calculated: 0, max_possible: 25, rating: 1 as const }
|
|
75
|
-
: calculateRawScore(payload.action_value, engagement.type, recency.divisor)
|
|
76
|
-
const scoredAt = new Date().toISOString()
|
|
77
|
-
|
|
78
|
-
// 5. INSERT SIGNAL ITEM
|
|
79
|
-
const signalData = {
|
|
80
|
-
identity: {
|
|
81
|
-
anonymous_id: payload.anonymous_id || null,
|
|
82
|
-
person_id: payload.person_id || null,
|
|
83
|
-
account_id: payload.account_id || null,
|
|
84
|
-
session_id: payload.session_id || null,
|
|
85
|
-
},
|
|
86
|
-
classification: {
|
|
87
|
-
stage: payload.stage,
|
|
88
|
-
source: payload.source,
|
|
89
|
-
pipeline_slug: payload.pipeline_slug || null,
|
|
90
|
-
},
|
|
91
|
-
action: {
|
|
92
|
-
action_type: payload.action_type,
|
|
93
|
-
action_value: payload.action_value,
|
|
94
|
-
action_description: payload.action_description || null,
|
|
95
|
-
url: payload.url || null,
|
|
96
|
-
},
|
|
97
|
-
scoring_components: {
|
|
98
|
-
engagement: { type: engagement.type, context: engagement.context, session_depth: engagement.session_depth, prior_session_count: engagement.prior_session_count || 0 },
|
|
99
|
-
recency: { divisor: recency.divisor, age_days: recency.age_days, window: recency.window },
|
|
100
|
-
raw_score: { calculated: scoring.calculated, max_possible: scoring.max_possible, rating: scoring.rating },
|
|
101
|
-
},
|
|
102
|
-
attribution: {
|
|
103
|
-
immediate_referrer: payload.referrer || null,
|
|
104
|
-
first_touch_referrer_domain: referrerDomain,
|
|
105
|
-
utm_source: payload.utm_source || null,
|
|
106
|
-
utm_medium: payload.utm_medium || null,
|
|
107
|
-
utm_campaign: payload.utm_campaign || null,
|
|
108
|
-
},
|
|
109
|
-
processing: {
|
|
110
|
-
received_at: receivedAt,
|
|
111
|
-
enriched_at: scoredAt,
|
|
112
|
-
scored_at: scoredAt,
|
|
113
|
-
stitched_at: null,
|
|
114
|
-
stitched_to_account_id: null,
|
|
115
|
-
},
|
|
116
|
-
source_metadata: {
|
|
117
|
-
instance_id: payload.instance_id || null,
|
|
118
|
-
environment: payload.environment || null,
|
|
119
|
-
},
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const { data: signalItem, error: signalErr } = await adminDb
|
|
123
|
-
.from('items')
|
|
124
|
-
.insert({
|
|
125
|
-
type_id: signalTypeId,
|
|
126
|
-
title: `${payload.action_type} - ${payload.action_value}`,
|
|
127
|
-
account_id: payload.account_id || unidentifiedAccountId,
|
|
128
|
-
data: signalData,
|
|
129
|
-
})
|
|
130
|
-
.select('id')
|
|
131
|
-
.single()
|
|
132
|
-
|
|
133
|
-
if (signalErr || !signalItem) {
|
|
134
|
-
console.error('[FunnelSignal] Failed to insert signal:', signalErr?.message)
|
|
135
|
-
return { status: 'error', error: 'Failed to create signal record' }
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const signalId = signalItem.id
|
|
139
|
-
|
|
140
|
-
// 6. UPDATE ACCOUNT or UPSERT ANONYMOUS SESSION
|
|
141
|
-
if (payload.account_id) {
|
|
142
|
-
await updateAccountFunnel(payload.account_id, payload.stage, scoring, payload.pipeline_slug, referrerDomain, referrerCategory, scoredAt)
|
|
143
|
-
if (signalLinkTypeId) {
|
|
144
|
-
await createLink(signalLinkTypeId, 'account', payload.account_id, 'item', signalId)
|
|
145
|
-
}
|
|
146
|
-
} else if (payload.anonymous_id && sessionTypeId && unidentifiedAccountId) {
|
|
147
|
-
await upsertAnonymousSession(payload, sessionTypeId, unidentifiedAccountId, signalId, scoring, engagement, referrerDomain, referrerCategory, scoredAt)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// 7. EVALUATE QUEUE — if rating >= 4
|
|
151
|
-
if (scoring.rating >= 4 && queueTypeId) {
|
|
152
|
-
await evaluateQueueEntry(payload, queueTypeId, opportunityLinkTypeId, signalId, scoring, scoredAt)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return {
|
|
156
|
-
status: 'success',
|
|
157
|
-
signal_id: signalId,
|
|
158
|
-
rating: scoring.rating,
|
|
159
|
-
raw_score: scoring.calculated,
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// ============================================
|
|
164
|
-
// VALIDATION
|
|
165
|
-
// ============================================
|
|
166
|
-
|
|
167
|
-
interface SignalPayload {
|
|
168
|
-
anonymous_id?: string
|
|
169
|
-
person_id?: string
|
|
170
|
-
account_id?: string
|
|
171
|
-
session_id?: string
|
|
172
|
-
stage: 'anonymous' | 'identified' | 'installed'
|
|
173
|
-
source: 'mar' | 'int' | 'use' | 'manual'
|
|
174
|
-
action_type: string
|
|
175
|
-
action_value: 1 | 2 | 5
|
|
176
|
-
action_description?: string
|
|
177
|
-
occurred_at?: string
|
|
178
|
-
url?: string
|
|
179
|
-
path?: string
|
|
180
|
-
referrer?: string
|
|
181
|
-
user_agent?: string
|
|
182
|
-
utm_source?: string
|
|
183
|
-
utm_medium?: string
|
|
184
|
-
utm_campaign?: string
|
|
185
|
-
instance_id?: string
|
|
186
|
-
environment?: 'dev' | 'staging' | 'production'
|
|
187
|
-
pipeline_slug?: string
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function validateSignalPayload(body: any): { valid: true; data: SignalPayload } | { valid: false; error: string } {
|
|
191
|
-
if (!body) return { valid: false, error: 'Missing request body' }
|
|
192
|
-
if (!body.stage || !['anonymous', 'identified', 'installed'].includes(body.stage)) return { valid: false, error: 'Invalid or missing stage' }
|
|
193
|
-
if (!body.source || !['mar', 'int', 'use', 'manual'].includes(body.source)) return { valid: false, error: 'Invalid or missing source' }
|
|
194
|
-
if (!body.action_type) return { valid: false, error: 'Missing action_type' }
|
|
195
|
-
if (!body.action_value || ![1, 2, 5].includes(body.action_value)) return { valid: false, error: 'Invalid action_value (must be 1, 2, or 5)' }
|
|
196
|
-
if (!body.anonymous_id && !body.person_id && !body.account_id) return { valid: false, error: 'Must provide anonymous_id, person_id, or account_id' }
|
|
197
|
-
if (body.source === 'mar' && !body.session_id) return { valid: false, error: 'session_id required for marketing signals' }
|
|
198
|
-
return { valid: true, data: body as SignalPayload }
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// ============================================
|
|
202
|
-
// ID RESOLUTION HELPERS
|
|
203
|
-
// ============================================
|
|
204
|
-
|
|
205
|
-
async function resolveUnidentifiedAccountId(): Promise<string | null> {
|
|
206
|
-
const { data } = await adminDb.from('accounts').select('id').eq('slug', 'unidentified-visitors').maybeSingle()
|
|
207
|
-
return data?.id || null
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
async function resolveTypeId(slug: string): Promise<string | null> {
|
|
211
|
-
const { data } = await adminDb.from('types').select('id').eq('slug', slug).eq('is_active', true).maybeSingle()
|
|
212
|
-
return data?.id || null
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
async function resolveLinkTypeId(slug: string): Promise<string | null> {
|
|
216
|
-
const { data } = await adminDb.from('link_types').select('id').eq('slug', slug).maybeSingle()
|
|
217
|
-
return data?.id || null
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// ============================================
|
|
221
|
-
// ENRICHMENT
|
|
222
|
-
// ============================================
|
|
223
|
-
|
|
224
|
-
async function fetchPriorSignals(payload: SignalPayload, signalTypeId: string): Promise<Array<{ session_id: string; occurred_at: string }>> {
|
|
225
|
-
let query = adminDb.from('items').select('data').eq('type_id', signalTypeId).order('created_at', { ascending: true }).limit(100)
|
|
226
|
-
if (payload.anonymous_id) {
|
|
227
|
-
query = query.eq('data->identity->>anonymous_id' as any, payload.anonymous_id)
|
|
228
|
-
} else if (payload.account_id) {
|
|
229
|
-
query = query.eq('account_id', payload.account_id)
|
|
230
|
-
} else {
|
|
231
|
-
return []
|
|
232
|
-
}
|
|
233
|
-
const { data } = await query
|
|
234
|
-
return (data || []).map((r: any) => ({
|
|
235
|
-
session_id: r.data?.identity?.session_id || 'default',
|
|
236
|
-
occurred_at: r.data?.processing?.received_at || r.data?.processing?.scored_at || new Date().toISOString(),
|
|
237
|
-
}))
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// ============================================
|
|
241
|
-
// ACCOUNT UPDATE
|
|
242
|
-
// ============================================
|
|
243
|
-
|
|
244
|
-
async function updateAccountFunnel(
|
|
245
|
-
accountId: string, stage: string,
|
|
246
|
-
scoring: { calculated: number; max_possible: number; rating: number },
|
|
247
|
-
pipelineSlug: string | undefined, referrerDomain: string, referrerCategory: string, now: string
|
|
248
|
-
): Promise<void> {
|
|
249
|
-
const { data: account } = await adminDb.from('accounts').select('data').eq('id', accountId).single()
|
|
250
|
-
if (!account) return
|
|
251
|
-
|
|
252
|
-
const currentData = account.data || {}
|
|
253
|
-
const temperature = ratingToTemperature(scoring.rating)
|
|
254
|
-
const ratingEntry = { rating: scoring.rating, raw_score: scoring.calculated, calculated_at: now }
|
|
255
|
-
|
|
256
|
-
const updatedRatings: Record<string, any> = { ...(currentData.ratings || {}) }
|
|
257
|
-
const currentStageRating = updatedRatings[stage]?.rating || 0
|
|
258
|
-
if (scoring.rating > currentStageRating) updatedRatings[stage] = ratingEntry
|
|
259
|
-
if (pipelineSlug) {
|
|
260
|
-
const currentPipelineRating = updatedRatings[pipelineSlug]?.rating || 0
|
|
261
|
-
if (scoring.rating >= currentPipelineRating) updatedRatings[pipelineSlug] = ratingEntry
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const attribution = currentData.attribution || {
|
|
265
|
-
first_touch_referrer_domain: referrerDomain,
|
|
266
|
-
first_touch_referrer_category: referrerCategory,
|
|
267
|
-
first_touch_occurred_at: now,
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
await adminDb.from('accounts').update({
|
|
271
|
-
data: {
|
|
272
|
-
...currentData,
|
|
273
|
-
lifecycle_stage: stage,
|
|
274
|
-
lead_score: scoring.calculated,
|
|
275
|
-
temperature,
|
|
276
|
-
last_signal_at: now,
|
|
277
|
-
ratings: updatedRatings,
|
|
278
|
-
attribution,
|
|
279
|
-
},
|
|
280
|
-
}).eq('id', accountId)
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// ============================================
|
|
284
|
-
// ANONYMOUS SESSION UPSERT
|
|
285
|
-
// ============================================
|
|
286
|
-
|
|
287
|
-
async function upsertAnonymousSession(
|
|
288
|
-
payload: SignalPayload, sessionTypeId: string, accountId: string, signalId: string,
|
|
289
|
-
scoring: { calculated: number; max_possible: number; rating: number },
|
|
290
|
-
engagement: any, referrerDomain: string, referrerCategory: string, now: string
|
|
291
|
-
): Promise<void> {
|
|
292
|
-
const { data: existing } = await adminDb
|
|
293
|
-
.from('items')
|
|
294
|
-
.select('id, data')
|
|
295
|
-
.eq('type_id', sessionTypeId)
|
|
296
|
-
.eq('data->>anonymous_id' as any, payload.anonymous_id!)
|
|
297
|
-
.order('created_at', { ascending: false })
|
|
298
|
-
.limit(1)
|
|
299
|
-
.maybeSingle()
|
|
300
|
-
|
|
301
|
-
if (existing) {
|
|
302
|
-
const d = existing.data || {}
|
|
303
|
-
const shouldUpdate = scoring.rating > (d.scoring_rating || 0)
|
|
304
|
-
await adminDb.from('items').update({
|
|
305
|
-
data: {
|
|
306
|
-
...d,
|
|
307
|
-
current_referrer_referrer_domain: referrerDomain,
|
|
308
|
-
current_referrer_referrer_url: payload.referrer || null,
|
|
309
|
-
current_referrer_occurred_at: now,
|
|
310
|
-
scoring_signal_count: (d.scoring_signal_count || 0) + 1,
|
|
311
|
-
lifecycle_last_activity_at: now,
|
|
312
|
-
...(shouldUpdate ? {
|
|
313
|
-
scoring_rating: scoring.rating,
|
|
314
|
-
scoring_raw_score: scoring.calculated,
|
|
315
|
-
scoring_calculated_at: now,
|
|
316
|
-
scoring_best_signal_id: signalId,
|
|
317
|
-
temperature: ratingToTemperature(scoring.rating),
|
|
318
|
-
} : {}),
|
|
319
|
-
},
|
|
320
|
-
}).eq('id', existing.id)
|
|
321
|
-
} else {
|
|
322
|
-
await adminDb.from('items').insert({
|
|
323
|
-
type_id: sessionTypeId,
|
|
324
|
-
account_id: accountId,
|
|
325
|
-
title: `Anonymous: ${payload.anonymous_id!.slice(0, 8)}`,
|
|
326
|
-
data: {
|
|
327
|
-
anonymous_id: payload.anonymous_id,
|
|
328
|
-
temperature: ratingToTemperature(scoring.rating),
|
|
329
|
-
current_stage: 'anonymous',
|
|
330
|
-
scoring_rating: scoring.rating,
|
|
331
|
-
scoring_raw_score: scoring.calculated,
|
|
332
|
-
scoring_signal_count: 1,
|
|
333
|
-
scoring_calculated_at: now,
|
|
334
|
-
scoring_best_signal_id: signalId,
|
|
335
|
-
first_touch_referrer_domain: referrerDomain,
|
|
336
|
-
first_touch_referrer_category: referrerCategory,
|
|
337
|
-
first_touch_referrer_url: payload.referrer || null,
|
|
338
|
-
first_touch_landing_page: payload.url || null,
|
|
339
|
-
first_touch_occurred_at: now,
|
|
340
|
-
first_touch_utm_source: payload.utm_source || null,
|
|
341
|
-
first_touch_utm_medium: payload.utm_medium || null,
|
|
342
|
-
first_touch_utm_campaign: payload.utm_campaign || null,
|
|
343
|
-
current_referrer_referrer_domain: referrerDomain,
|
|
344
|
-
current_referrer_referrer_url: payload.referrer || null,
|
|
345
|
-
current_referrer_occurred_at: now,
|
|
346
|
-
lifecycle_created_at: now,
|
|
347
|
-
lifecycle_last_activity_at: now,
|
|
348
|
-
retention_retention_days: 90,
|
|
349
|
-
retention_purge_after: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
|
|
350
|
-
},
|
|
351
|
-
})
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// ============================================
|
|
356
|
-
// LINK CREATION
|
|
357
|
-
// ============================================
|
|
358
|
-
|
|
359
|
-
async function createLink(
|
|
360
|
-
linkTypeId: string, sourceType: string, sourceId: string, targetType: string, targetId: string
|
|
361
|
-
): Promise<void> {
|
|
362
|
-
await adminDb.from('links').insert({
|
|
363
|
-
link_type_id: linkTypeId,
|
|
364
|
-
source_type: sourceType,
|
|
365
|
-
source_id: sourceId,
|
|
366
|
-
target_type: targetType,
|
|
367
|
-
target_id: targetId,
|
|
368
|
-
}).catch((err: any) => console.error('[FunnelSignal] Link insert failed:', err.message))
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// ============================================
|
|
372
|
-
// QUEUE ENTRY EVALUATION
|
|
373
|
-
// ============================================
|
|
374
|
-
|
|
375
|
-
async function evaluateQueueEntry(
|
|
376
|
-
payload: SignalPayload, queueTypeId: string, opportunityLinkTypeId: string | null,
|
|
377
|
-
signalId: string, scoring: { calculated: number; max_possible: number; rating: number }, now: string
|
|
378
|
-
): Promise<void> {
|
|
379
|
-
const inference = inferOpportunityType(
|
|
380
|
-
[{ action: { action_type: payload.action_type } }],
|
|
381
|
-
payload.stage, scoring.rating
|
|
382
|
-
)
|
|
383
|
-
|
|
384
|
-
const { data: queueItem } = await adminDb.from('items').insert({
|
|
385
|
-
type_id: queueTypeId,
|
|
386
|
-
title: `${inference.type} - ${inference.confidence} priority`,
|
|
387
|
-
account_id: payload.account_id || null,
|
|
388
|
-
data: {
|
|
389
|
-
identity_account_id: payload.account_id || null,
|
|
390
|
-
trigger_source_signal_id: signalId,
|
|
391
|
-
trigger_trigger_stage: payload.stage,
|
|
392
|
-
trigger_trigger_rating: scoring.rating,
|
|
393
|
-
trigger_trigger_raw_score: scoring.calculated,
|
|
394
|
-
trigger_trigger_reason: `High engagement: ${inference.type}`,
|
|
395
|
-
recommendation_opportunity_type: inference.type,
|
|
396
|
-
recommendation_confidence: inference.confidence,
|
|
397
|
-
recommendation_suggested_priority: Math.min(scoring.rating, 5),
|
|
398
|
-
review_status: 'pending',
|
|
399
|
-
notes_auto_reason: `Auto-generated: ${inference.type} opportunity detected with confidence ${inference.confidence}`,
|
|
400
|
-
},
|
|
401
|
-
}).select('id').single().then((r: { data: { id: string } | null }) => r.data).catch(() => null)
|
|
402
|
-
|
|
403
|
-
if (payload.account_id && queueItem?.id) {
|
|
404
|
-
const { data: acct } = await adminDb.from('accounts').select('data').eq('id', payload.account_id).single()
|
|
405
|
-
if (acct) {
|
|
406
|
-
await adminDb.from('accounts').update({
|
|
407
|
-
data: { ...(acct.data || {}), queue: { pending_opportunity_id: queueItem.id } },
|
|
408
|
-
}).eq('id', payload.account_id)
|
|
409
|
-
}
|
|
410
|
-
if (opportunityLinkTypeId) {
|
|
411
|
-
await createLink(opportunityLinkTypeId, 'account', payload.account_id, 'item', queueItem.id)
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// ============================================
|
|
417
|
-
// UTILITIES
|
|
418
|
-
// ============================================
|
|
419
|
-
|
|
420
|
-
function extractDomain(url: string | undefined): string {
|
|
421
|
-
if (!url) return 'direct'
|
|
422
|
-
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
function ratingToTemperature(rating: number): 'cold' | 'warm' | 'hot' | 'on_fire' {
|
|
426
|
-
if (rating <= 2) return 'cold'
|
|
427
|
-
if (rating <= 3) return 'warm'
|
|
428
|
-
if (rating <= 4) return 'hot'
|
|
429
|
-
return 'on_fire'
|
|
430
|
-
}
|