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