spine-framework-cortex 0.1.1
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/components/CortexSidebar.tsx +130 -0
- package/functions/custom_anonymous-sessions.ts +356 -0
- package/functions/custom_case_analysis.ts +507 -0
- package/functions/custom_community-escalation.ts +234 -0
- package/functions/custom_cortex-chunks.ts +52 -0
- package/functions/custom_cortex-handler.ts +35 -0
- package/functions/custom_funnel-scoring.ts +256 -0
- package/functions/custom_funnel-signal.ts +678 -0
- package/functions/custom_funnel-timers.ts +449 -0
- package/functions/custom_kb-chunker-test.ts +364 -0
- package/functions/custom_kb-chunker.ts +576 -0
- package/functions/custom_kb-embeddings.ts +481 -0
- package/functions/custom_kb-ingestion.ts +448 -0
- package/functions/custom_support-triage.ts +649 -0
- package/functions/custom_tag_management.ts +314 -0
- package/index.tsx +103 -0
- package/manifest.json +82 -0
- package/package.json +29 -0
- package/pages/CortexDashboard.tsx +97 -0
- package/pages/community/CommunityPage.tsx +159 -0
- package/pages/courses/CoursesPage.tsx +231 -0
- package/pages/crm/AccountDetailPage.tsx +393 -0
- package/pages/crm/AccountsPage.tsx +164 -0
- package/pages/crm/ActivityPage.tsx +82 -0
- package/pages/crm/ContactDetailPage.tsx +184 -0
- package/pages/crm/ContactsPage.tsx +87 -0
- package/pages/crm/DealDetailPage.tsx +191 -0
- package/pages/crm/DealsPage.tsx +169 -0
- package/pages/crm/HealthPage.tsx +109 -0
- package/pages/intelligence/IntelligencePage.tsx +314 -0
- package/pages/kb/KBEditorPage.tsx +328 -0
- package/pages/kb/KBIngestionPage.tsx +409 -0
- package/pages/kb/KBPage.tsx +258 -0
- package/pages/support/RedactionReview.tsx +562 -0
- package/pages/support/SupportPage.tsx +395 -0
- package/pages/support/TicketDetailPage.tsx +919 -0
- package/seed/accounts.json +9 -0
- package/seed/link-types.json +44 -0
- package/seed/triggers.json +80 -0
- package/seed/types.json +352 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
// Funnel Signal Handler
|
|
2
|
+
// Processes incoming funnel signals using ONLY Spine APIs (ctx.db)
|
|
3
|
+
// NO direct database access
|
|
4
|
+
//
|
|
5
|
+
// Handler Signature (per integration-routes.ts):
|
|
6
|
+
// scriptHandler(sanitizedData, scriptContext, scriptEvent)
|
|
7
|
+
// - sanitizedData: request body
|
|
8
|
+
// - scriptContext: { integrationId, accountId, slug, principal, requestId, headers }
|
|
9
|
+
// - scriptEvent: { httpMethod, headers, body, path, queryStringParameters }
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
calculateEngagement,
|
|
13
|
+
calculateRecency,
|
|
14
|
+
calculateRawScore,
|
|
15
|
+
inferOpportunityType,
|
|
16
|
+
categorizeReferrer,
|
|
17
|
+
EngagementResult,
|
|
18
|
+
RecencyResult,
|
|
19
|
+
RawScoreResult
|
|
20
|
+
} from './custom_funnel-scoring'
|
|
21
|
+
import { adminDb } from './_shared/db'
|
|
22
|
+
import { resolveTypeIds, resolveLinkTypeIds, resolveAccountId } from './_shared/resolve-ids'
|
|
23
|
+
|
|
24
|
+
// IDs resolved at request time — never hardcoded
|
|
25
|
+
async function resolveIds() {
|
|
26
|
+
const [types, linkTypes, unidentifiedVisitorsAccountId] = await Promise.all([
|
|
27
|
+
resolveTypeIds([
|
|
28
|
+
{ kind: 'item', slug: 'funnel_signal' },
|
|
29
|
+
{ kind: 'item', slug: 'anonymous_session' },
|
|
30
|
+
{ kind: 'item', slug: 'opportunity_queue' },
|
|
31
|
+
]),
|
|
32
|
+
resolveLinkTypeIds(['account_signals', 'account_opportunities']),
|
|
33
|
+
resolveAccountId('unidentified-visitors'),
|
|
34
|
+
])
|
|
35
|
+
return {
|
|
36
|
+
TYPE_IDS: {
|
|
37
|
+
funnel_signal: types['item/funnel_signal'],
|
|
38
|
+
anonymous_session: types['item/anonymous_session'],
|
|
39
|
+
opportunity_queue: types['item/opportunity_queue'],
|
|
40
|
+
},
|
|
41
|
+
LINK_TYPE_IDS: {
|
|
42
|
+
account_signals: linkTypes['account_signals'],
|
|
43
|
+
account_opportunities: linkTypes['account_opportunities'],
|
|
44
|
+
},
|
|
45
|
+
UNIDENTIFIED_VISITORS_ACCOUNT_ID: unidentifiedVisitorsAccountId,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ============================================
|
|
50
|
+
// SIGNAL HANDLER (Integration Routes Compatible)
|
|
51
|
+
// ============================================
|
|
52
|
+
|
|
53
|
+
export async function processSignal(
|
|
54
|
+
sanitizedData: any,
|
|
55
|
+
scriptContext: any,
|
|
56
|
+
_scriptEvent: any
|
|
57
|
+
) {
|
|
58
|
+
const receivedAt = new Date().toISOString()
|
|
59
|
+
|
|
60
|
+
// 1. VALIDATE PAYLOAD
|
|
61
|
+
const signal = validateSignalPayload(sanitizedData)
|
|
62
|
+
if (!signal.valid) {
|
|
63
|
+
return { status: 'error', error: (signal as { valid: false; error: string }).error }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const payload = signal.data
|
|
67
|
+
|
|
68
|
+
// Resolve IDs at request time — never hardcoded
|
|
69
|
+
const ids = await resolveIds()
|
|
70
|
+
|
|
71
|
+
// 2. ENRICH (using adminDb Spine APIs)
|
|
72
|
+
const enrichment = await enrichSignal(payload, ids)
|
|
73
|
+
|
|
74
|
+
// 3. SCORE
|
|
75
|
+
const scoring = scoreSignal(payload, enrichment)
|
|
76
|
+
|
|
77
|
+
// 4. CREATE SIGNAL ITEM (adminDb.from('items').insert())
|
|
78
|
+
const signalItem = await createSignalItem(payload, enrichment, scoring, receivedAt, ids)
|
|
79
|
+
|
|
80
|
+
// 5. UPDATE ACCOUNT OR CREATE ANONYMOUS SESSION
|
|
81
|
+
let accountUpdate = null
|
|
82
|
+
let sessionItem = null
|
|
83
|
+
|
|
84
|
+
if (payload.account_id) {
|
|
85
|
+
accountUpdate = await updateAccountFunnel(payload.account_id, payload.stage, scoring)
|
|
86
|
+
|
|
87
|
+
// Create link between account and signal
|
|
88
|
+
await createAccountSignalLink(payload.account_id, signalItem.id, ids)
|
|
89
|
+
} else if (payload.anonymous_id) {
|
|
90
|
+
sessionItem = await upsertAnonymousSession(payload, enrichment, scoring, signalItem.id, ids)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 6. EVALUATE QUEUE (if rating >= 4)
|
|
94
|
+
let queueEntry = null
|
|
95
|
+
if (scoring.rating >= 4) {
|
|
96
|
+
queueEntry = await evaluateQueueEntry(payload, scoring, signalItem.id, ids)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
status: 'success',
|
|
101
|
+
signal_id: signalItem.id,
|
|
102
|
+
rating: scoring.rating,
|
|
103
|
+
raw_score: scoring.calculated,
|
|
104
|
+
account_updated: !!accountUpdate,
|
|
105
|
+
session_created: !!sessionItem,
|
|
106
|
+
queue_entry: queueEntry
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ============================================
|
|
111
|
+
// VALIDATION
|
|
112
|
+
// ============================================
|
|
113
|
+
|
|
114
|
+
function validateSignalPayload(body: any): { valid: true; data: SignalPayload } | { valid: false; error: string } {
|
|
115
|
+
if (!body) {
|
|
116
|
+
return { valid: false, error: 'Missing request body' }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check required fields
|
|
120
|
+
if (!body.stage || !['anonymous', 'identified', 'installed'].includes(body.stage)) {
|
|
121
|
+
return { valid: false, error: 'Invalid or missing stage' }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!body.source || !['mar', 'int', 'use', 'manual'].includes(body.source)) {
|
|
125
|
+
return { valid: false, error: 'Invalid or missing source' }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!body.action_type) {
|
|
129
|
+
return { valid: false, error: 'Missing action_type' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!body.action_value || ![1, 2, 5].includes(body.action_value)) {
|
|
133
|
+
return { valid: false, error: 'Invalid action_value (must be 1, 2, or 5)' }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Check identity - must have at least one
|
|
137
|
+
if (!body.anonymous_id && !body.person_id && !body.account_id) {
|
|
138
|
+
return { valid: false, error: 'Must provide anonymous_id, person_id, or account_id' }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// For 'mar' source, session_id is required
|
|
142
|
+
if (body.source === 'mar' && !body.session_id) {
|
|
143
|
+
return { valid: false, error: 'session_id required for marketing signals' }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { valid: true, data: body as SignalPayload }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface SignalPayload {
|
|
150
|
+
anonymous_id?: string
|
|
151
|
+
person_id?: string
|
|
152
|
+
account_id?: string
|
|
153
|
+
session_id?: string
|
|
154
|
+
stage: 'anonymous' | 'identified' | 'installed'
|
|
155
|
+
source: 'mar' | 'int' | 'use' | 'manual'
|
|
156
|
+
action_type: string
|
|
157
|
+
action_value: 1 | 2 | 5
|
|
158
|
+
action_description?: string
|
|
159
|
+
occurred_at?: string
|
|
160
|
+
url?: string
|
|
161
|
+
path?: string
|
|
162
|
+
referrer?: string
|
|
163
|
+
user_agent?: string
|
|
164
|
+
utm_source?: string
|
|
165
|
+
utm_medium?: string
|
|
166
|
+
utm_campaign?: string
|
|
167
|
+
instance_id?: string
|
|
168
|
+
environment?: 'dev' | 'staging' | 'production'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ============================================
|
|
172
|
+
// ENRICHMENT (using adminDb Spine APIs)
|
|
173
|
+
// ============================================
|
|
174
|
+
|
|
175
|
+
async function enrichSignal(payload: SignalPayload, ids: Awaited<ReturnType<typeof resolveIds>>): Promise<EnrichmentResult> {
|
|
176
|
+
const occurredAt = payload.occurred_at ? new Date(payload.occurred_at) : new Date()
|
|
177
|
+
|
|
178
|
+
// Query prior signals for engagement calculation
|
|
179
|
+
let priorSignals: any[] = []
|
|
180
|
+
|
|
181
|
+
if (payload.anonymous_id) {
|
|
182
|
+
// Query by anonymous_id using adminDb
|
|
183
|
+
const { data } = await adminDb
|
|
184
|
+
.from('items')
|
|
185
|
+
.select('data->>session_id as session_id, data->processing->>scored_at as occurred_at')
|
|
186
|
+
.eq('type_id', ids.TYPE_IDS.funnel_signal)
|
|
187
|
+
.eq('data->identity->>anonymous_id', payload.anonymous_id)
|
|
188
|
+
.order('created_at', { ascending: true })
|
|
189
|
+
.limit(100)
|
|
190
|
+
|
|
191
|
+
priorSignals = (data as any[]) || []
|
|
192
|
+
} else if (payload.account_id) {
|
|
193
|
+
// Query by account_id using adminDb
|
|
194
|
+
const { data } = await adminDb
|
|
195
|
+
.from('items')
|
|
196
|
+
.select('data->>session_id as session_id, data->processing->>scored_at as occurred_at')
|
|
197
|
+
.eq('type_id', ids.TYPE_IDS.funnel_signal)
|
|
198
|
+
.eq('account_id', payload.account_id)
|
|
199
|
+
.order('created_at', { ascending: true })
|
|
200
|
+
.limit(100)
|
|
201
|
+
|
|
202
|
+
priorSignals = (data as any[]) || []
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Calculate engagement
|
|
206
|
+
const engagement = calculateEngagement(
|
|
207
|
+
priorSignals,
|
|
208
|
+
payload.session_id || 'default',
|
|
209
|
+
occurredAt.toISOString(),
|
|
210
|
+
payload.stage
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
// Calculate recency
|
|
214
|
+
const recency = calculateRecency(occurredAt, new Date(), payload.stage)
|
|
215
|
+
|
|
216
|
+
// Extract referrer
|
|
217
|
+
const referrerDomain = extractDomain(payload.referrer)
|
|
218
|
+
const referrerCategory = categorizeReferrer(referrerDomain)
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
engagement,
|
|
222
|
+
recency,
|
|
223
|
+
referrer_domain: referrerDomain,
|
|
224
|
+
referrer_category: referrerCategory,
|
|
225
|
+
occurred_at: occurredAt.toISOString()
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
interface EnrichmentResult {
|
|
230
|
+
engagement: EngagementResult
|
|
231
|
+
recency: RecencyResult
|
|
232
|
+
referrer_domain: string
|
|
233
|
+
referrer_category: string
|
|
234
|
+
occurred_at: string
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ============================================
|
|
238
|
+
// SCORING
|
|
239
|
+
// ============================================
|
|
240
|
+
|
|
241
|
+
function scoreSignal(payload: SignalPayload, enrichment: EnrichmentResult): RawScoreResult {
|
|
242
|
+
if (enrichment.recency.divisor === null) {
|
|
243
|
+
// Expired signal gets minimum score
|
|
244
|
+
return { calculated: 0, max_possible: 25, rating: 1 }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return calculateRawScore(
|
|
248
|
+
payload.action_value,
|
|
249
|
+
enrichment.engagement.type,
|
|
250
|
+
enrichment.recency.divisor
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ============================================
|
|
255
|
+
// CREATE SIGNAL ITEM (adminDb.from('items').insert())
|
|
256
|
+
// ============================================
|
|
257
|
+
|
|
258
|
+
async function createSignalItem(
|
|
259
|
+
payload: SignalPayload,
|
|
260
|
+
enrichment: EnrichmentResult,
|
|
261
|
+
scoring: RawScoreResult,
|
|
262
|
+
receivedAt: string,
|
|
263
|
+
ids: Awaited<ReturnType<typeof resolveIds>>
|
|
264
|
+
): Promise<{ id: string }> {
|
|
265
|
+
const scoredAt = new Date().toISOString()
|
|
266
|
+
|
|
267
|
+
const signalData = {
|
|
268
|
+
identity: {
|
|
269
|
+
anonymous_id: payload.anonymous_id || null,
|
|
270
|
+
person_id: payload.person_id || null,
|
|
271
|
+
account_id: payload.account_id || null,
|
|
272
|
+
session_id: payload.session_id || null
|
|
273
|
+
},
|
|
274
|
+
classification: {
|
|
275
|
+
stage: payload.stage,
|
|
276
|
+
source: payload.source
|
|
277
|
+
},
|
|
278
|
+
action: {
|
|
279
|
+
action_type: payload.action_type,
|
|
280
|
+
action_value: payload.action_value,
|
|
281
|
+
action_description: payload.action_description || null
|
|
282
|
+
},
|
|
283
|
+
scoring_components: {
|
|
284
|
+
engagement: {
|
|
285
|
+
type: enrichment.engagement.type,
|
|
286
|
+
context: enrichment.engagement.context,
|
|
287
|
+
session_depth: enrichment.engagement.session_depth,
|
|
288
|
+
prior_session_count: enrichment.engagement.prior_session_count || 0
|
|
289
|
+
},
|
|
290
|
+
recency: {
|
|
291
|
+
divisor: enrichment.recency.divisor,
|
|
292
|
+
age_days: enrichment.recency.age_days,
|
|
293
|
+
window: enrichment.recency.window
|
|
294
|
+
},
|
|
295
|
+
raw_score: {
|
|
296
|
+
calculated: scoring.calculated,
|
|
297
|
+
max_possible: scoring.max_possible,
|
|
298
|
+
rating: scoring.rating
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
attribution: {
|
|
302
|
+
first_touch_referrer_domain: enrichment.referrer_domain,
|
|
303
|
+
immediate_referrer: payload.referrer || null,
|
|
304
|
+
utm_source: payload.utm_source || null,
|
|
305
|
+
utm_medium: payload.utm_medium || null,
|
|
306
|
+
utm_campaign: payload.utm_campaign || null
|
|
307
|
+
},
|
|
308
|
+
processing: {
|
|
309
|
+
received_at: receivedAt,
|
|
310
|
+
enriched_at: scoredAt,
|
|
311
|
+
scored_at: scoredAt,
|
|
312
|
+
stitched_at: null,
|
|
313
|
+
stitched_to_account_id: null
|
|
314
|
+
},
|
|
315
|
+
source_metadata: {
|
|
316
|
+
instance_id: payload.instance_id || null,
|
|
317
|
+
environment: payload.environment || null
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const { data, error } = await adminDb
|
|
322
|
+
.from('items')
|
|
323
|
+
.insert({
|
|
324
|
+
type_id: ids.TYPE_IDS.funnel_signal,
|
|
325
|
+
title: `${payload.action_type} - ${payload.action_value}`,
|
|
326
|
+
account_id: payload.account_id || ids.UNIDENTIFIED_VISITORS_ACCOUNT_ID,
|
|
327
|
+
data: signalData
|
|
328
|
+
})
|
|
329
|
+
.select('id')
|
|
330
|
+
.single()
|
|
331
|
+
|
|
332
|
+
if (error) {
|
|
333
|
+
throw new Error(`Failed to create signal: ${error.message}`)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return { id: data.id }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ============================================
|
|
340
|
+
// UPDATE ACCOUNT FUNNEL (adminDb.from('accounts').update())
|
|
341
|
+
// ============================================
|
|
342
|
+
|
|
343
|
+
async function updateAccountFunnel(
|
|
344
|
+
accountId: string,
|
|
345
|
+
stage: string,
|
|
346
|
+
scoring: RawScoreResult
|
|
347
|
+
): Promise<boolean> {
|
|
348
|
+
// Get current account data
|
|
349
|
+
const { data: account, error: fetchError } = await adminDb
|
|
350
|
+
.from('accounts')
|
|
351
|
+
.select('data')
|
|
352
|
+
.eq('id', accountId)
|
|
353
|
+
.single()
|
|
354
|
+
|
|
355
|
+
if (fetchError || !account) {
|
|
356
|
+
console.error(`[FunnelSignal] Account not found: ${accountId}`)
|
|
357
|
+
return false
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const now = new Date().toISOString()
|
|
361
|
+
|
|
362
|
+
// Only update if this is the best signal for this stage
|
|
363
|
+
const currentStageRating = account.data?.ratings?.[stage]?.rating || 0
|
|
364
|
+
const shouldUpdate = scoring.rating > currentStageRating
|
|
365
|
+
|
|
366
|
+
if (!shouldUpdate) {
|
|
367
|
+
// Just update last_signal_at
|
|
368
|
+
await adminDb
|
|
369
|
+
.from('accounts')
|
|
370
|
+
.update({
|
|
371
|
+
data: { ...account.data, last_signal_at: now }
|
|
372
|
+
})
|
|
373
|
+
.eq('id', accountId)
|
|
374
|
+
|
|
375
|
+
return true
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Update rating and temperature — write flat to data so UI can read directly
|
|
379
|
+
const temperature = ratingToTemperature(scoring.rating)
|
|
380
|
+
const updatedRatings = {
|
|
381
|
+
...(account.data?.ratings || {}),
|
|
382
|
+
[stage]: {
|
|
383
|
+
rating: scoring.rating,
|
|
384
|
+
raw_score: scoring.calculated,
|
|
385
|
+
calculated_at: now
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const { error } = await adminDb
|
|
390
|
+
.from('accounts')
|
|
391
|
+
.update({
|
|
392
|
+
data: {
|
|
393
|
+
...account.data,
|
|
394
|
+
lifecycle_stage: stage,
|
|
395
|
+
lead_score: scoring.calculated,
|
|
396
|
+
temperature,
|
|
397
|
+
last_signal_at: now,
|
|
398
|
+
ratings: updatedRatings,
|
|
399
|
+
attribution: account.data?.attribution || null
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
.eq('id', accountId)
|
|
403
|
+
|
|
404
|
+
if (error) {
|
|
405
|
+
console.error(`[FunnelSignal] Failed to update account: ${error.message}`)
|
|
406
|
+
return false
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return true
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ============================================
|
|
413
|
+
// UPSERT ANONYMOUS SESSION (adminDb.from('items').insert() / .update())
|
|
414
|
+
// ============================================
|
|
415
|
+
|
|
416
|
+
async function upsertAnonymousSession(
|
|
417
|
+
payload: SignalPayload,
|
|
418
|
+
enrichment: EnrichmentResult,
|
|
419
|
+
scoring: RawScoreResult,
|
|
420
|
+
signalId: string,
|
|
421
|
+
ids: Awaited<ReturnType<typeof resolveIds>>
|
|
422
|
+
): Promise<{ id: string; created: boolean }> {
|
|
423
|
+
const now = new Date().toISOString()
|
|
424
|
+
|
|
425
|
+
// Try to find existing session
|
|
426
|
+
const { data: existingSession } = await adminDb
|
|
427
|
+
.from('items')
|
|
428
|
+
.select('id, data')
|
|
429
|
+
.eq('type_id', ids.TYPE_IDS.anonymous_session)
|
|
430
|
+
.eq('data->identity->>anonymous_id', payload.anonymous_id!)
|
|
431
|
+
.eq('is_active', true)
|
|
432
|
+
.order('created_at', { ascending: false })
|
|
433
|
+
.limit(1)
|
|
434
|
+
.maybeSingle()
|
|
435
|
+
|
|
436
|
+
if (existingSession) {
|
|
437
|
+
// Update existing session
|
|
438
|
+
const currentData = existingSession.data || {}
|
|
439
|
+
const currentRatings = currentData.scoring?.ratings || {}
|
|
440
|
+
|
|
441
|
+
const shouldUpdateRating = !currentRatings.anonymous || scoring.rating > currentRatings.anonymous.rating
|
|
442
|
+
|
|
443
|
+
const updatedData = {
|
|
444
|
+
...currentData,
|
|
445
|
+
attribution: {
|
|
446
|
+
...currentData.attribution,
|
|
447
|
+
current_referrer: {
|
|
448
|
+
referrer_domain: enrichment.referrer_domain,
|
|
449
|
+
referrer_url: payload.referrer || null,
|
|
450
|
+
occurred_at: now
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
scoring: {
|
|
454
|
+
...currentData.scoring,
|
|
455
|
+
ratings: {
|
|
456
|
+
anonymous: shouldUpdateRating ? {
|
|
457
|
+
rating: scoring.rating,
|
|
458
|
+
raw_score: scoring.calculated,
|
|
459
|
+
calculated_at: now,
|
|
460
|
+
best_signal_id: signalId,
|
|
461
|
+
signal_count: (currentRatings.anonymous?.signal_count || 0) + 1
|
|
462
|
+
} : currentRatings.anonymous
|
|
463
|
+
},
|
|
464
|
+
temperature: shouldUpdateRating ? ratingToTemperature(scoring.rating) : currentData.scoring?.temperature
|
|
465
|
+
},
|
|
466
|
+
lifecycle: {
|
|
467
|
+
...currentData.lifecycle,
|
|
468
|
+
last_activity_at: now
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
await adminDb
|
|
473
|
+
.from('items')
|
|
474
|
+
.update({ data: updatedData, updated_at: now })
|
|
475
|
+
.eq('id', existingSession.id)
|
|
476
|
+
|
|
477
|
+
return { id: existingSession.id, created: false }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Create new session
|
|
481
|
+
const sessionData = {
|
|
482
|
+
identity: {
|
|
483
|
+
anonymous_id: payload.anonymous_id
|
|
484
|
+
},
|
|
485
|
+
attribution: {
|
|
486
|
+
first_touch: {
|
|
487
|
+
referrer_domain: enrichment.referrer_domain,
|
|
488
|
+
referrer_url: payload.referrer || null,
|
|
489
|
+
referrer_category: enrichment.referrer_category,
|
|
490
|
+
landing_page: payload.url || null,
|
|
491
|
+
landing_page_category: null,
|
|
492
|
+
occurred_at: now,
|
|
493
|
+
utm_source: payload.utm_source || null,
|
|
494
|
+
utm_medium: payload.utm_medium || null,
|
|
495
|
+
utm_campaign: payload.utm_campaign || null
|
|
496
|
+
},
|
|
497
|
+
current_referrer: {
|
|
498
|
+
referrer_domain: enrichment.referrer_domain,
|
|
499
|
+
referrer_url: payload.referrer || null,
|
|
500
|
+
occurred_at: now
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
scoring: {
|
|
504
|
+
ratings: {
|
|
505
|
+
anonymous: {
|
|
506
|
+
rating: scoring.rating,
|
|
507
|
+
raw_score: scoring.calculated,
|
|
508
|
+
calculated_at: now,
|
|
509
|
+
best_signal_id: signalId,
|
|
510
|
+
signal_count: 1
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
current_stage: 'anonymous',
|
|
514
|
+
temperature: ratingToTemperature(scoring.rating)
|
|
515
|
+
},
|
|
516
|
+
lifecycle: {
|
|
517
|
+
created_at: now,
|
|
518
|
+
last_activity_at: now,
|
|
519
|
+
stitched_at: null,
|
|
520
|
+
stitched_to_account_id: null,
|
|
521
|
+
stitched_to_person_id: null
|
|
522
|
+
},
|
|
523
|
+
retention: {
|
|
524
|
+
retention_days: 90,
|
|
525
|
+
purge_after: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString()
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const { data, error } = await adminDb
|
|
530
|
+
.from('items')
|
|
531
|
+
.insert({
|
|
532
|
+
type_id: ids.TYPE_IDS.anonymous_session,
|
|
533
|
+
title: `Anonymous: ${payload.anonymous_id!.slice(0, 8)}`,
|
|
534
|
+
account_id: payload.account_id || ids.UNIDENTIFIED_VISITORS_ACCOUNT_ID,
|
|
535
|
+
data: sessionData
|
|
536
|
+
})
|
|
537
|
+
.select('id')
|
|
538
|
+
.single()
|
|
539
|
+
|
|
540
|
+
if (error) {
|
|
541
|
+
throw new Error(`Failed to create anonymous session: ${error.message}`)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return { id: data.id, created: true }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ============================================
|
|
548
|
+
// CREATE ACCOUNT-SIGNAL LINK (adminDb.from('links').insert())
|
|
549
|
+
// ============================================
|
|
550
|
+
|
|
551
|
+
async function createAccountSignalLink(accountId: string, signalId: string, ids: Awaited<ReturnType<typeof resolveIds>>): Promise<void> {
|
|
552
|
+
const { error } = await adminDb
|
|
553
|
+
.from('links')
|
|
554
|
+
.insert({
|
|
555
|
+
link_type_id: ids.LINK_TYPE_IDS.account_signals,
|
|
556
|
+
source_type: 'account',
|
|
557
|
+
source_id: accountId,
|
|
558
|
+
target_type: 'item',
|
|
559
|
+
target_id: signalId,
|
|
560
|
+
data: { created_at: new Date().toISOString() }
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
if (error) {
|
|
564
|
+
console.error(`[FunnelSignal] Failed to create link: ${error.message}`)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ============================================
|
|
569
|
+
// EVALUATE QUEUE ENTRY
|
|
570
|
+
// ============================================
|
|
571
|
+
|
|
572
|
+
async function evaluateQueueEntry(
|
|
573
|
+
payload: SignalPayload,
|
|
574
|
+
scoring: RawScoreResult,
|
|
575
|
+
signalId: string,
|
|
576
|
+
ids: Awaited<ReturnType<typeof resolveIds>>
|
|
577
|
+
): Promise<{ id: string } | null> {
|
|
578
|
+
// Infer opportunity type
|
|
579
|
+
const inference = inferOpportunityType([{ action: payload }], payload.stage, scoring.rating)
|
|
580
|
+
|
|
581
|
+
const now = new Date().toISOString()
|
|
582
|
+
|
|
583
|
+
const queueData = {
|
|
584
|
+
identity: {
|
|
585
|
+
account_id: payload.account_id || null,
|
|
586
|
+
person_id: payload.person_id || null
|
|
587
|
+
},
|
|
588
|
+
trigger: {
|
|
589
|
+
source_signal_id: signalId,
|
|
590
|
+
trigger_stage: payload.stage,
|
|
591
|
+
trigger_rating: scoring.rating,
|
|
592
|
+
trigger_raw_score: scoring.calculated,
|
|
593
|
+
trigger_reason: `High engagement: ${inference.type}`
|
|
594
|
+
},
|
|
595
|
+
recommendation: {
|
|
596
|
+
opportunity_type: inference.type,
|
|
597
|
+
confidence: inference.confidence,
|
|
598
|
+
suggested_priority: Math.min(scoring.rating, 5)
|
|
599
|
+
},
|
|
600
|
+
review: {
|
|
601
|
+
status: 'pending',
|
|
602
|
+
reviewed_by: null,
|
|
603
|
+
reviewed_at: null,
|
|
604
|
+
conversion_opportunity_id: null
|
|
605
|
+
},
|
|
606
|
+
notes: {
|
|
607
|
+
reviewer_notes: null,
|
|
608
|
+
auto_reason: `Auto-generated: ${inference.type} opportunity detected with confidence ${inference.confidence}`
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const { data, error } = await adminDb
|
|
613
|
+
.from('items')
|
|
614
|
+
.insert({
|
|
615
|
+
type_id: ids.TYPE_IDS.opportunity_queue,
|
|
616
|
+
title: `${inference.type} - ${inference.confidence} priority`,
|
|
617
|
+
account_id: payload.account_id || ids.UNIDENTIFIED_VISITORS_ACCOUNT_ID,
|
|
618
|
+
data: queueData
|
|
619
|
+
})
|
|
620
|
+
.select('id')
|
|
621
|
+
.single()
|
|
622
|
+
|
|
623
|
+
if (error) {
|
|
624
|
+
console.error(`[FunnelSignal] Failed to create queue entry: ${error.message}`)
|
|
625
|
+
return null
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// If we have an account, update the queue reference and create link
|
|
629
|
+
if (payload.account_id) {
|
|
630
|
+
const { data: acct } = await adminDb
|
|
631
|
+
.from('accounts')
|
|
632
|
+
.select('data')
|
|
633
|
+
.eq('id', payload.account_id)
|
|
634
|
+
.single()
|
|
635
|
+
|
|
636
|
+
await adminDb
|
|
637
|
+
.from('accounts')
|
|
638
|
+
.update({
|
|
639
|
+
data: {
|
|
640
|
+
...(acct?.data || {}),
|
|
641
|
+
queue: { pending_opportunity_id: data.id }
|
|
642
|
+
}
|
|
643
|
+
})
|
|
644
|
+
.eq('id', payload.account_id)
|
|
645
|
+
|
|
646
|
+
await adminDb
|
|
647
|
+
.from('links')
|
|
648
|
+
.insert({
|
|
649
|
+
link_type_id: ids.LINK_TYPE_IDS.account_opportunities,
|
|
650
|
+
source_type: 'account',
|
|
651
|
+
source_id: payload.account_id,
|
|
652
|
+
target_type: 'item',
|
|
653
|
+
target_id: data.id
|
|
654
|
+
})
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return { id: data.id }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ============================================
|
|
661
|
+
// UTILITY FUNCTIONS
|
|
662
|
+
// ============================================
|
|
663
|
+
|
|
664
|
+
function extractDomain(url: string | undefined): string {
|
|
665
|
+
if (!url) return 'direct'
|
|
666
|
+
try {
|
|
667
|
+
const urlObj = new URL(url)
|
|
668
|
+
return urlObj.hostname.replace(/^www\./, '')
|
|
669
|
+
} catch {
|
|
670
|
+
return url
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function ratingToTemperature(rating: number): 'cold' | 'warm' | 'hot' {
|
|
675
|
+
if (rating <= 2) return 'cold'
|
|
676
|
+
if (rating <= 3) return 'warm'
|
|
677
|
+
return 'hot'
|
|
678
|
+
}
|