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.
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 -42
  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 -430
  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,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
- }