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.
Files changed (40) hide show
  1. package/components/CortexSidebar.tsx +130 -0
  2. package/functions/custom_anonymous-sessions.ts +356 -0
  3. package/functions/custom_case_analysis.ts +507 -0
  4. package/functions/custom_community-escalation.ts +234 -0
  5. package/functions/custom_cortex-chunks.ts +52 -0
  6. package/functions/custom_cortex-handler.ts +35 -0
  7. package/functions/custom_funnel-scoring.ts +256 -0
  8. package/functions/custom_funnel-signal.ts +678 -0
  9. package/functions/custom_funnel-timers.ts +449 -0
  10. package/functions/custom_kb-chunker-test.ts +364 -0
  11. package/functions/custom_kb-chunker.ts +576 -0
  12. package/functions/custom_kb-embeddings.ts +481 -0
  13. package/functions/custom_kb-ingestion.ts +448 -0
  14. package/functions/custom_support-triage.ts +649 -0
  15. package/functions/custom_tag_management.ts +314 -0
  16. package/index.tsx +103 -0
  17. package/manifest.json +82 -0
  18. package/package.json +29 -0
  19. package/pages/CortexDashboard.tsx +97 -0
  20. package/pages/community/CommunityPage.tsx +159 -0
  21. package/pages/courses/CoursesPage.tsx +231 -0
  22. package/pages/crm/AccountDetailPage.tsx +393 -0
  23. package/pages/crm/AccountsPage.tsx +164 -0
  24. package/pages/crm/ActivityPage.tsx +82 -0
  25. package/pages/crm/ContactDetailPage.tsx +184 -0
  26. package/pages/crm/ContactsPage.tsx +87 -0
  27. package/pages/crm/DealDetailPage.tsx +191 -0
  28. package/pages/crm/DealsPage.tsx +169 -0
  29. package/pages/crm/HealthPage.tsx +109 -0
  30. package/pages/intelligence/IntelligencePage.tsx +314 -0
  31. package/pages/kb/KBEditorPage.tsx +328 -0
  32. package/pages/kb/KBIngestionPage.tsx +409 -0
  33. package/pages/kb/KBPage.tsx +258 -0
  34. package/pages/support/RedactionReview.tsx +562 -0
  35. package/pages/support/SupportPage.tsx +395 -0
  36. package/pages/support/TicketDetailPage.tsx +919 -0
  37. package/seed/accounts.json +9 -0
  38. package/seed/link-types.json +44 -0
  39. package/seed/triggers.json +80 -0
  40. 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
+ }