spine-framework-cortex 0.1.14 → 0.1.15

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/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.1.15] — 2026-06-09
8
+ - Fixed `integrations.json` handler config format for `funnel-signal-mar` and `funnel-signal-use` integrations
9
+ - Removed orphaned `on_funnel_signal_created` trigger from `triggers.json` — trigger engine requires `pipeline_id` and cannot directly invoke custom functions
10
+ - Removed orphaned `funnel-scoring-trigger` handler registration from `webhook-handlers.ts`
11
+ - Deleted orphaned `custom_funnel-scoring-trigger.ts` file
12
+ - Restored `custom_funnel-signal.ts` as complete inline handler with full scoring, account updates, link creation, and queue evaluation
13
+
7
14
  ## [0.1.14] — 2026-06-09
8
15
  - Added **Command Center** page (`/ops/command-center`) — KPI strip, Hot Opportunities table, Audit/Install funnel tab strip, funnel health panels
9
16
  - Added **Audit Funnel** page (`/ops/audit-funnel`) — account list filtered to `funnel-ai-audit` pipeline, sorted by signal score, with recommended offer logic
@@ -1,56 +1,37 @@
1
1
  // Funnel Signal Handler
2
- // Processes incoming funnel signals using ONLY Spine APIs (ctx.db)
3
- // NO direct database access
2
+ // Ingests, scores, and persists funnel signals end-to-end.
4
3
  //
5
4
  // Handler Signature (per integration-routes.ts):
6
5
  // scriptHandler(sanitizedData, scriptContext, scriptEvent)
7
- // - sanitizedData: request body
6
+ // - sanitizedData: request body (sanitized by integration-routes)
8
7
  // - scriptContext: { integrationId, accountId, slug, principal, requestId, headers }
9
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.
10
13
 
14
+ import { adminDb } from './_shared/db'
11
15
  import {
12
16
  calculateEngagement,
13
17
  calculateRecency,
14
18
  calculateRawScore,
15
19
  inferOpportunityType,
16
20
  categorizeReferrer,
17
- EngagementResult,
18
- RecencyResult,
19
- RawScoreResult
20
21
  } from './custom_funnel-scoring'
21
- import { adminDb } from './_shared/db'
22
- import { resolveTypeIds, resolveLinkTypeIds, resolveAccountId } from './_shared/resolve-ids'
23
- import { fireCreateTriggers, SYSTEM_PRINCIPAL } from './_shared/index'
24
-
25
- // IDs resolved at request time — never hardcoded
26
- async function resolveIds() {
27
- const [types, linkTypes, unidentifiedVisitorsAccountId] = await Promise.all([
28
- resolveTypeIds([
29
- { kind: 'item', slug: 'funnel_signal' },
30
- { kind: 'item', slug: 'anonymous_session' },
31
- { kind: 'item', slug: 'opportunity_queue' },
32
- ]),
33
- resolveLinkTypeIds(['account_signals', 'account_opportunities']),
34
- resolveAccountId('unidentified-visitors'),
35
- ])
36
- return {
37
- TYPE_IDS: {
38
- funnel_signal: types['item/funnel_signal'],
39
- anonymous_session: types['item/anonymous_session'],
40
- opportunity_queue: types['item/opportunity_queue'],
41
- },
42
- LINK_TYPE_IDS: {
43
- account_signals: linkTypes['account_signals'],
44
- account_opportunities: linkTypes['account_opportunities'],
45
- },
46
- UNIDENTIFIED_VISITORS_ACCOUNT_ID: unidentifiedVisitorsAccountId,
47
- }
48
- }
49
22
 
50
23
  // ============================================
51
24
  // SIGNAL HANDLER (Integration Routes Compatible)
52
25
  // ============================================
53
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
+
54
35
  export async function processSignal(
55
36
  sanitizedData: any,
56
37
  scriptContext: any,
@@ -58,62 +39,124 @@ export async function processSignal(
58
39
  ) {
59
40
  const receivedAt = new Date().toISOString()
60
41
 
61
- // 1. VALIDATE PAYLOAD
62
- const signal = validateSignalPayload(sanitizedData)
63
- if (!signal.valid) {
64
- return { status: 'error', error: (signal as { valid: false; error: string }).error }
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' }
65
62
  }
66
63
 
67
- const payload = signal.data
64
+ // 3. ENRICH — fetch prior signals for this identity
65
+ const priorSignals = await fetchPriorSignals(payload, signalTypeId)
68
66
 
69
- // Resolve IDs at request time — never hardcoded
70
- const ids = await resolveIds()
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()
71
77
 
72
- // 2. ENRICH (using adminDb Spine APIs)
73
- const enrichment = await enrichSignal(payload, ids)
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
+ }
74
121
 
75
- // 3. SCORE
76
- const scoring = scoreSignal(payload, enrichment)
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()
77
132
 
78
- // 4. CREATE SIGNAL ITEM (adminDb.from('items').insert())
79
- const signalItem = await createSignalItem(payload, enrichment, scoring, receivedAt, ids)
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
+ }
80
137
 
81
- // 5. UPDATE ACCOUNT OR CREATE ANONYMOUS SESSION
82
- let accountUpdate = null
83
- let sessionItem = null
138
+ const signalId = signalItem.id
84
139
 
140
+ // 6. UPDATE ACCOUNT or UPSERT ANONYMOUS SESSION
85
141
  if (payload.account_id) {
86
- accountUpdate = await updateAccountFunnel(payload.account_id, payload.stage, scoring, payload.pipeline_slug)
87
-
88
- // Create link between account and signal
89
- await createAccountSignalLink(payload.account_id, signalItem.id, ids)
90
- } else if (payload.anonymous_id) {
91
- sessionItem = await upsertAnonymousSession(payload, enrichment, scoring, signalItem.id, ids)
92
- }
93
-
94
- // 6. EVALUATE QUEUE (if rating >= 4)
95
- let queueEntry = null
96
- if (scoring.rating >= 4) {
97
- queueEntry = await evaluateQueueEntry(payload, scoring, signalItem.id, ids)
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) {
147
+ await upsertAnonymousSession(payload, sessionTypeId, signalId, scoring, engagement, referrerDomain, referrerCategory, scoredAt)
98
148
  }
99
149
 
100
- // 7. FIRE TRIGGERSlets core pipeline/trigger engine handle downstream automation
101
- const triggerCtx = {
102
- principal: SYSTEM_PRINCIPAL,
103
- accountId: payload.account_id || ids.UNIDENTIFIED_VISITORS_ACCOUNT_ID,
104
- db: adminDb,
105
- requestId: scriptContext?.requestId || crypto.randomUUID(),
150
+ // 7. EVALUATE QUEUEif rating >= 4
151
+ if (scoring.rating >= 4 && queueTypeId) {
152
+ await evaluateQueueEntry(payload, queueTypeId, opportunityLinkTypeId, signalId, scoring, scoredAt)
106
153
  }
107
- await fireCreateTriggers('item', signalItem.id, signalItem, triggerCtx)
108
154
 
109
155
  return {
110
156
  status: 'success',
111
- signal_id: signalItem.id,
157
+ signal_id: signalId,
112
158
  rating: scoring.rating,
113
159
  raw_score: scoring.calculated,
114
- account_updated: !!accountUpdate,
115
- session_created: !!sessionItem,
116
- queue_entry: queueEntry
117
160
  }
118
161
  }
119
162
 
@@ -121,41 +164,6 @@ export async function processSignal(
121
164
  // VALIDATION
122
165
  // ============================================
123
166
 
124
- function validateSignalPayload(body: any): { valid: true; data: SignalPayload } | { valid: false; error: string } {
125
- if (!body) {
126
- return { valid: false, error: 'Missing request body' }
127
- }
128
-
129
- // Check required fields
130
- if (!body.stage || !['anonymous', 'identified', 'installed'].includes(body.stage)) {
131
- return { valid: false, error: 'Invalid or missing stage' }
132
- }
133
-
134
- if (!body.source || !['mar', 'int', 'use', 'manual'].includes(body.source)) {
135
- return { valid: false, error: 'Invalid or missing source' }
136
- }
137
-
138
- if (!body.action_type) {
139
- return { valid: false, error: 'Missing action_type' }
140
- }
141
-
142
- if (!body.action_value || ![1, 2, 5].includes(body.action_value)) {
143
- return { valid: false, error: 'Invalid action_value (must be 1, 2, or 5)' }
144
- }
145
-
146
- // Check identity - must have at least one
147
- if (!body.anonymous_id && !body.person_id && !body.account_id) {
148
- return { valid: false, error: 'Must provide anonymous_id, person_id, or account_id' }
149
- }
150
-
151
- // For 'mar' source, session_id is required
152
- if (body.source === 'mar' && !body.session_id) {
153
- return { valid: false, error: 'session_id required for marketing signals' }
154
- }
155
-
156
- return { valid: true, data: body as SignalPayload }
157
- }
158
-
159
167
  interface SignalPayload {
160
168
  anonymous_id?: string
161
169
  person_id?: string
@@ -179,517 +187,243 @@ interface SignalPayload {
179
187
  pipeline_slug?: string
180
188
  }
181
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
+
182
201
  // ============================================
183
- // ENRICHMENT (using adminDb Spine APIs)
202
+ // ID RESOLUTION HELPERS
184
203
  // ============================================
185
204
 
186
- async function enrichSignal(payload: SignalPayload, ids: Awaited<ReturnType<typeof resolveIds>>): Promise<EnrichmentResult> {
187
- const occurredAt = payload.occurred_at ? new Date(payload.occurred_at) : new Date()
188
-
189
- // Query prior signals for engagement calculation
190
- let priorSignals: any[] = []
191
-
192
- if (payload.anonymous_id) {
193
- // Query by anonymous_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('data->identity->>anonymous_id', payload.anonymous_id)
199
- .order('created_at', { ascending: true })
200
- .limit(100)
201
-
202
- priorSignals = (data as any[]) || []
203
- } else if (payload.account_id) {
204
- // Query by account_id using adminDb
205
- const { data } = await adminDb
206
- .from('items')
207
- .select('data->>session_id as session_id, data->processing->>scored_at as occurred_at')
208
- .eq('type_id', ids.TYPE_IDS.funnel_signal)
209
- .eq('account_id', payload.account_id)
210
- .order('created_at', { ascending: true })
211
- .limit(100)
212
-
213
- priorSignals = (data as any[]) || []
214
- }
215
-
216
- // Calculate engagement
217
- const engagement = calculateEngagement(
218
- priorSignals,
219
- payload.session_id || 'default',
220
- occurredAt.toISOString(),
221
- payload.stage
222
- )
223
-
224
- // Calculate recency
225
- const recency = calculateRecency(occurredAt, new Date(), payload.stage)
226
-
227
- // Extract referrer
228
- const referrerDomain = extractDomain(payload.referrer)
229
- const referrerCategory = categorizeReferrer(referrerDomain)
230
-
231
- return {
232
- engagement,
233
- recency,
234
- referrer_domain: referrerDomain,
235
- referrer_category: referrerCategory,
236
- occurred_at: occurredAt.toISOString()
237
- }
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
238
208
  }
239
209
 
240
- interface EnrichmentResult {
241
- engagement: EngagementResult
242
- recency: RecencyResult
243
- referrer_domain: string
244
- referrer_category: string
245
- occurred_at: string
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
246
213
  }
247
214
 
248
- // ============================================
249
- // SCORING
250
- // ============================================
251
-
252
- function scoreSignal(payload: SignalPayload, enrichment: EnrichmentResult): RawScoreResult {
253
- if (enrichment.recency.divisor === null) {
254
- // Expired signal gets minimum score
255
- return { calculated: 0, max_possible: 25, rating: 1 }
256
- }
257
-
258
- return calculateRawScore(
259
- payload.action_value,
260
- enrichment.engagement.type,
261
- enrichment.recency.divisor
262
- )
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
263
218
  }
264
219
 
265
220
  // ============================================
266
- // CREATE SIGNAL ITEM (adminDb.from('items').insert())
221
+ // ENRICHMENT
267
222
  // ============================================
268
223
 
269
- async function createSignalItem(
270
- payload: SignalPayload,
271
- enrichment: EnrichmentResult,
272
- scoring: RawScoreResult,
273
- receivedAt: string,
274
- ids: Awaited<ReturnType<typeof resolveIds>>
275
- ): Promise<{ id: string }> {
276
- const scoredAt = new Date().toISOString()
277
-
278
- const signalData = {
279
- identity: {
280
- anonymous_id: payload.anonymous_id || null,
281
- person_id: payload.person_id || null,
282
- account_id: payload.account_id || null,
283
- session_id: payload.session_id || null
284
- },
285
- classification: {
286
- stage: payload.stage,
287
- source: payload.source,
288
- pipeline_slug: payload.pipeline_slug || null
289
- },
290
- action: {
291
- action_type: payload.action_type,
292
- action_value: payload.action_value,
293
- action_description: payload.action_description || null
294
- },
295
- scoring_components: {
296
- engagement: {
297
- type: enrichment.engagement.type,
298
- context: enrichment.engagement.context,
299
- session_depth: enrichment.engagement.session_depth,
300
- prior_session_count: enrichment.engagement.prior_session_count || 0
301
- },
302
- recency: {
303
- divisor: enrichment.recency.divisor,
304
- age_days: enrichment.recency.age_days,
305
- window: enrichment.recency.window
306
- },
307
- raw_score: {
308
- calculated: scoring.calculated,
309
- max_possible: scoring.max_possible,
310
- rating: scoring.rating
311
- }
312
- },
313
- attribution: {
314
- first_touch_referrer_domain: enrichment.referrer_domain,
315
- immediate_referrer: payload.referrer || null,
316
- utm_source: payload.utm_source || null,
317
- utm_medium: payload.utm_medium || null,
318
- utm_campaign: payload.utm_campaign || null
319
- },
320
- processing: {
321
- received_at: receivedAt,
322
- enriched_at: scoredAt,
323
- scored_at: scoredAt,
324
- stitched_at: null,
325
- stitched_to_account_id: null
326
- },
327
- source_metadata: {
328
- instance_id: payload.instance_id || null,
329
- environment: payload.environment || null
330
- }
331
- }
332
-
333
- const { data, error } = await adminDb
334
- .from('items')
335
- .insert({
336
- type_id: ids.TYPE_IDS.funnel_signal,
337
- title: `${payload.action_type} - ${payload.action_value}`,
338
- account_id: payload.account_id || ids.UNIDENTIFIED_VISITORS_ACCOUNT_ID,
339
- data: signalData
340
- })
341
- .select('id')
342
- .single()
343
-
344
- if (error) {
345
- throw new Error(`Failed to create signal: ${error.message}`)
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 []
346
232
  }
347
-
348
- return { id: data.id }
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
+ }))
349
238
  }
350
239
 
351
240
  // ============================================
352
- // UPDATE ACCOUNT FUNNEL (adminDb.from('accounts').update())
241
+ // ACCOUNT UPDATE
353
242
  // ============================================
354
243
 
355
244
  async function updateAccountFunnel(
356
- accountId: string,
357
- stage: string,
358
- scoring: RawScoreResult,
359
- pipelineSlug?: string
360
- ): Promise<boolean> {
361
- // Get current account data
362
- const { data: account, error: fetchError } = await adminDb
363
- .from('accounts')
364
- .select('data')
365
- .eq('id', accountId)
366
- .single()
367
-
368
- if (fetchError || !account) {
369
- console.error(`[FunnelSignal] Account not found: ${accountId}`)
370
- return false
371
- }
372
-
373
- const now = new Date().toISOString()
374
-
375
- // Only update if this is the best signal for this stage
376
- const currentStageRating = account.data?.ratings?.[stage]?.rating || 0
377
- const shouldUpdate = scoring.rating > currentStageRating
378
-
379
- if (!shouldUpdate) {
380
- // Just update last_signal_at
381
- await adminDb
382
- .from('accounts')
383
- .update({
384
- data: { ...account.data, last_signal_at: now }
385
- })
386
- .eq('id', accountId)
387
-
388
- return true
389
- }
390
-
391
- // Update rating and temperature — write flat to data so UI can read directly
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 || {}
392
253
  const temperature = ratingToTemperature(scoring.rating)
393
254
  const ratingEntry = { rating: scoring.rating, raw_score: scoring.calculated, calculated_at: now }
394
- const updatedRatings: Record<string, any> = {
395
- ...(account.data?.ratings || {}),
396
- [stage]: ratingEntry,
397
- }
398
- // Also store per-pipeline_slug so UI can show per-funnel score breakdown
255
+
256
+ const updatedRatings: Record<string, any> = { ...(currentData.ratings || {}) }
257
+ const currentStageRating = updatedRatings[stage]?.rating || 0
258
+ if (scoring.rating > currentStageRating) updatedRatings[stage] = ratingEntry
399
259
  if (pipelineSlug) {
400
260
  const currentPipelineRating = updatedRatings[pipelineSlug]?.rating || 0
401
- if (scoring.rating >= currentPipelineRating) {
402
- updatedRatings[pipelineSlug] = ratingEntry
403
- }
261
+ if (scoring.rating >= currentPipelineRating) updatedRatings[pipelineSlug] = ratingEntry
404
262
  }
405
263
 
406
- const { error } = await adminDb
407
- .from('accounts')
408
- .update({
409
- data: {
410
- ...account.data,
411
- lifecycle_stage: stage,
412
- lead_score: scoring.calculated,
413
- temperature,
414
- last_signal_at: now,
415
- ratings: updatedRatings,
416
- attribution: account.data?.attribution || null
417
- }
418
- })
419
- .eq('id', accountId)
420
-
421
- if (error) {
422
- console.error(`[FunnelSignal] Failed to update account: ${error.message}`)
423
- return false
264
+ const attribution = currentData.attribution || {
265
+ first_touch_referrer_domain: referrerDomain,
266
+ first_touch_referrer_category: referrerCategory,
267
+ first_touch_occurred_at: now,
424
268
  }
425
269
 
426
- return true
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)
427
281
  }
428
282
 
429
283
  // ============================================
430
- // UPSERT ANONYMOUS SESSION (adminDb.from('items').insert() / .update())
284
+ // ANONYMOUS SESSION UPSERT
431
285
  // ============================================
432
286
 
433
287
  async function upsertAnonymousSession(
434
- payload: SignalPayload,
435
- enrichment: EnrichmentResult,
436
- scoring: RawScoreResult,
437
- signalId: string,
438
- ids: Awaited<ReturnType<typeof resolveIds>>
439
- ): Promise<{ id: string; created: boolean }> {
440
- const now = new Date().toISOString()
441
-
442
- // Try to find existing session
443
- const { data: existingSession } = await adminDb
288
+ payload: SignalPayload, sessionTypeId: 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
444
293
  .from('items')
445
294
  .select('id, data')
446
- .eq('type_id', ids.TYPE_IDS.anonymous_session)
447
- .eq('data->identity->>anonymous_id', payload.anonymous_id!)
448
- .eq('is_active', true)
295
+ .eq('type_id', sessionTypeId)
296
+ .eq('data->identity->>anonymous_id' as any, payload.anonymous_id!)
449
297
  .order('created_at', { ascending: false })
450
298
  .limit(1)
451
299
  .maybeSingle()
452
300
 
453
- if (existingSession) {
454
- // Update existing session
455
- const currentData = existingSession.data || {}
456
- const currentRatings = currentData.scoring?.ratings || {}
457
-
458
- const shouldUpdateRating = !currentRatings.anonymous || scoring.rating > currentRatings.anonymous.rating
459
-
460
- const updatedData = {
461
- ...currentData,
462
- attribution: {
463
- ...currentData.attribution,
464
- current_referrer: {
465
- referrer_domain: enrichment.referrer_domain,
466
- referrer_url: payload.referrer || null,
467
- occurred_at: now
468
- }
469
- },
470
- scoring: {
471
- ...currentData.scoring,
472
- ratings: {
473
- anonymous: shouldUpdateRating ? {
474
- rating: scoring.rating,
475
- raw_score: scoring.calculated,
476
- calculated_at: now,
477
- best_signal_id: signalId,
478
- signal_count: (currentRatings.anonymous?.signal_count || 0) + 1
479
- } : currentRatings.anonymous
480
- },
481
- temperature: shouldUpdateRating ? ratingToTemperature(scoring.rating) : currentData.scoring?.temperature
482
- },
483
- lifecycle: {
484
- ...currentData.lifecycle,
485
- last_activity_at: now
486
- }
487
- }
488
-
489
- await adminDb
490
- .from('items')
491
- .update({ data: updatedData, updated_at: now })
492
- .eq('id', existingSession.id)
493
-
494
- return { id: existingSession.id, created: false }
495
- }
496
-
497
- // Create new session
498
- const sessionData = {
499
- identity: {
500
- anonymous_id: payload.anonymous_id
501
- },
502
- attribution: {
503
- first_touch: {
504
- referrer_domain: enrichment.referrer_domain,
505
- referrer_url: payload.referrer || null,
506
- referrer_category: enrichment.referrer_category,
507
- landing_page: payload.url || null,
508
- landing_page_category: null,
509
- occurred_at: now,
510
- utm_source: payload.utm_source || null,
511
- utm_medium: payload.utm_medium || null,
512
- utm_campaign: payload.utm_campaign || null
513
- },
514
- current_referrer: {
515
- referrer_domain: enrichment.referrer_domain,
516
- referrer_url: payload.referrer || null,
517
- occurred_at: now
518
- }
519
- },
520
- scoring: {
521
- ratings: {
522
- anonymous: {
523
- rating: scoring.rating,
524
- raw_score: scoring.calculated,
525
- calculated_at: now,
526
- best_signal_id: signalId,
527
- signal_count: 1
528
- }
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
+ } : {}),
529
319
  },
530
- current_stage: 'anonymous',
531
- temperature: ratingToTemperature(scoring.rating)
532
- },
533
- lifecycle: {
534
- created_at: now,
535
- last_activity_at: now,
536
- stitched_at: null,
537
- stitched_to_account_id: null,
538
- stitched_to_person_id: null
539
- },
540
- retention: {
541
- retention_days: 90,
542
- purge_after: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString()
543
- }
544
- }
545
-
546
- const { data, error } = await adminDb
547
- .from('items')
548
- .insert({
549
- type_id: ids.TYPE_IDS.anonymous_session,
320
+ }).eq('id', existing.id)
321
+ } else {
322
+ await adminDb.from('items').insert({
323
+ type_id: sessionTypeId,
550
324
  title: `Anonymous: ${payload.anonymous_id!.slice(0, 8)}`,
551
- account_id: payload.account_id || ids.UNIDENTIFIED_VISITORS_ACCOUNT_ID,
552
- data: sessionData
325
+ data: {
326
+ identity: { anonymous_id: payload.anonymous_id },
327
+ temperature: ratingToTemperature(scoring.rating),
328
+ current_stage: 'anonymous',
329
+ scoring_rating: scoring.rating,
330
+ scoring_raw_score: scoring.calculated,
331
+ scoring_signal_count: 1,
332
+ scoring_calculated_at: now,
333
+ scoring_best_signal_id: signalId,
334
+ first_touch_referrer_domain: referrerDomain,
335
+ first_touch_referrer_category: referrerCategory,
336
+ first_touch_referrer_url: payload.referrer || null,
337
+ first_touch_landing_page: payload.url || null,
338
+ first_touch_occurred_at: now,
339
+ first_touch_utm_source: payload.utm_source || null,
340
+ first_touch_utm_medium: payload.utm_medium || null,
341
+ first_touch_utm_campaign: payload.utm_campaign || null,
342
+ current_referrer_referrer_domain: referrerDomain,
343
+ current_referrer_referrer_url: payload.referrer || null,
344
+ current_referrer_occurred_at: now,
345
+ lifecycle_created_at: now,
346
+ lifecycle_last_activity_at: now,
347
+ retention_retention_days: 90,
348
+ retention_purge_after: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
349
+ },
553
350
  })
554
- .select('id')
555
- .single()
556
-
557
- if (error) {
558
- throw new Error(`Failed to create anonymous session: ${error.message}`)
559
351
  }
560
-
561
- return { id: data.id, created: true }
562
352
  }
563
353
 
564
354
  // ============================================
565
- // CREATE ACCOUNT-SIGNAL LINK (adminDb.from('links').insert())
355
+ // LINK CREATION
566
356
  // ============================================
567
357
 
568
- async function createAccountSignalLink(accountId: string, signalId: string, ids: Awaited<ReturnType<typeof resolveIds>>): Promise<void> {
569
- const { error } = await adminDb
570
- .from('links')
571
- .insert({
572
- link_type_id: ids.LINK_TYPE_IDS.account_signals,
573
- source_type: 'account',
574
- source_id: accountId,
575
- target_type: 'item',
576
- target_id: signalId,
577
- data: { created_at: new Date().toISOString() }
578
- })
579
-
580
- if (error) {
581
- console.error(`[FunnelSignal] Failed to create link: ${error.message}`)
582
- }
358
+ async function createLink(
359
+ linkTypeId: string, sourceType: string, sourceId: string, targetType: string, targetId: string
360
+ ): Promise<void> {
361
+ await adminDb.from('links').insert({
362
+ link_type_id: linkTypeId,
363
+ source_type: sourceType,
364
+ source_id: sourceId,
365
+ target_type: targetType,
366
+ target_id: targetId,
367
+ }).catch((err: any) => console.error('[FunnelSignal] Link insert failed:', err.message))
583
368
  }
584
369
 
585
370
  // ============================================
586
- // EVALUATE QUEUE ENTRY
371
+ // QUEUE ENTRY EVALUATION
587
372
  // ============================================
588
373
 
589
374
  async function evaluateQueueEntry(
590
- payload: SignalPayload,
591
- scoring: RawScoreResult,
592
- signalId: string,
593
- ids: Awaited<ReturnType<typeof resolveIds>>
594
- ): Promise<{ id: string } | null> {
595
- // Infer opportunity type
596
- const inference = inferOpportunityType([{ action: payload }], payload.stage, scoring.rating)
597
-
598
- const now = new Date().toISOString()
375
+ payload: SignalPayload, queueTypeId: string, opportunityLinkTypeId: string | null,
376
+ signalId: string, scoring: { calculated: number; max_possible: number; rating: number }, now: string
377
+ ): Promise<void> {
378
+ const inference = inferOpportunityType(
379
+ [{ action: { action_type: payload.action_type } }],
380
+ payload.stage, scoring.rating
381
+ )
599
382
 
600
- const queueData = {
601
- identity: {
602
- account_id: payload.account_id || null,
603
- person_id: payload.person_id || null
604
- },
605
- trigger: {
606
- source_signal_id: signalId,
607
- trigger_stage: payload.stage,
608
- trigger_rating: scoring.rating,
609
- trigger_raw_score: scoring.calculated,
610
- trigger_reason: `High engagement: ${inference.type}`
611
- },
612
- recommendation: {
613
- opportunity_type: inference.type,
614
- confidence: inference.confidence,
615
- suggested_priority: Math.min(scoring.rating, 5)
616
- },
617
- review: {
618
- status: 'pending',
619
- reviewed_by: null,
620
- reviewed_at: null,
621
- conversion_opportunity_id: null
383
+ const { data: queueItem } = await adminDb.from('items').insert({
384
+ type_id: queueTypeId,
385
+ title: `${inference.type} - ${inference.confidence} priority`,
386
+ account_id: payload.account_id || null,
387
+ data: {
388
+ identity_account_id: payload.account_id || null,
389
+ trigger_source_signal_id: signalId,
390
+ trigger_trigger_stage: payload.stage,
391
+ trigger_trigger_rating: scoring.rating,
392
+ trigger_trigger_raw_score: scoring.calculated,
393
+ trigger_trigger_reason: `High engagement: ${inference.type}`,
394
+ recommendation_opportunity_type: inference.type,
395
+ recommendation_confidence: inference.confidence,
396
+ recommendation_suggested_priority: Math.min(scoring.rating, 5),
397
+ review_status: 'pending',
398
+ notes_auto_reason: `Auto-generated: ${inference.type} opportunity detected with confidence ${inference.confidence}`,
622
399
  },
623
- notes: {
624
- reviewer_notes: null,
625
- auto_reason: `Auto-generated: ${inference.type} opportunity detected with confidence ${inference.confidence}`
400
+ }).select('id').single().then((r: { data: { id: string } | null }) => r.data).catch(() => null)
401
+
402
+ if (payload.account_id && queueItem?.id) {
403
+ const { data: acct } = await adminDb.from('accounts').select('data').eq('id', payload.account_id).single()
404
+ if (acct) {
405
+ await adminDb.from('accounts').update({
406
+ data: { ...(acct.data || {}), queue: { pending_opportunity_id: queueItem.id } },
407
+ }).eq('id', payload.account_id)
408
+ }
409
+ if (opportunityLinkTypeId) {
410
+ await createLink(opportunityLinkTypeId, 'account', payload.account_id, 'item', queueItem.id)
626
411
  }
627
412
  }
628
-
629
- const { data, error } = await adminDb
630
- .from('items')
631
- .insert({
632
- type_id: ids.TYPE_IDS.opportunity_queue,
633
- title: `${inference.type} - ${inference.confidence} priority`,
634
- account_id: payload.account_id || ids.UNIDENTIFIED_VISITORS_ACCOUNT_ID,
635
- data: queueData
636
- })
637
- .select('id')
638
- .single()
639
-
640
- if (error) {
641
- console.error(`[FunnelSignal] Failed to create queue entry: ${error.message}`)
642
- return null
643
- }
644
-
645
- // If we have an account, update the queue reference and create link
646
- if (payload.account_id) {
647
- const { data: acct } = await adminDb
648
- .from('accounts')
649
- .select('data')
650
- .eq('id', payload.account_id)
651
- .single()
652
-
653
- await adminDb
654
- .from('accounts')
655
- .update({
656
- data: {
657
- ...(acct?.data || {}),
658
- queue: { pending_opportunity_id: data.id }
659
- }
660
- })
661
- .eq('id', payload.account_id)
662
-
663
- await adminDb
664
- .from('links')
665
- .insert({
666
- link_type_id: ids.LINK_TYPE_IDS.account_opportunities,
667
- source_type: 'account',
668
- source_id: payload.account_id,
669
- target_type: 'item',
670
- target_id: data.id
671
- })
672
- }
673
-
674
- return { id: data.id }
675
413
  }
676
414
 
677
415
  // ============================================
678
- // UTILITY FUNCTIONS
416
+ // UTILITIES
679
417
  // ============================================
680
418
 
681
419
  function extractDomain(url: string | undefined): string {
682
420
  if (!url) return 'direct'
683
- try {
684
- const urlObj = new URL(url)
685
- return urlObj.hostname.replace(/^www\./, '')
686
- } catch {
687
- return url
688
- }
421
+ try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
689
422
  }
690
423
 
691
- function ratingToTemperature(rating: number): 'cold' | 'warm' | 'hot' {
424
+ function ratingToTemperature(rating: number): 'cold' | 'warm' | 'hot' | 'on_fire' {
692
425
  if (rating <= 2) return 'cold'
693
426
  if (rating <= 3) return 'warm'
694
- return 'hot'
427
+ if (rating <= 4) return 'hot'
428
+ return 'on_fire'
695
429
  }
@@ -26,3 +26,4 @@ registerWebhookHandler({
26
26
  description: 'Funnel signal processing webhook handler',
27
27
  events: ['integration.webhook'],
28
28
  }).catch(console.error)
29
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework-cortex",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Cortex — AI-powered support, CRM, and knowledge base app for Spine Framework",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -11,11 +11,11 @@ interface FunnelSignal {
11
11
  id: string
12
12
  title: string
13
13
  data: {
14
- signal_type: string
15
- score_delta: number
16
- account_id?: string
17
- person_id?: string
18
- occurred_at: string
14
+ action?: { action_type?: string; action_value?: number }
15
+ identity?: { account_id?: string; person_id?: string; anonymous_id?: string }
16
+ classification?: { stage?: string; source?: string }
17
+ scoring_components?: { raw_score?: { rating?: number; calculated?: number } }
18
+ processing?: { received_at?: string; scored_at?: string }
19
19
  }
20
20
  created_at: string
21
21
  }
@@ -95,7 +95,7 @@ export default function IntelligencePage() {
95
95
 
96
96
  useEffect(() => {
97
97
  if (accountsData) {
98
- setAccounts(accountsData.filter((acc: any) => acc.data?.lead_score !== undefined))
98
+ setAccounts(accountsData.filter((acc: any) => acc.data?.lead_score !== undefined || acc.data?.lifecycle_stage))
99
99
  }
100
100
  }, [accountsData])
101
101
 
@@ -206,28 +206,35 @@ export default function IntelligencePage() {
206
206
  <CardContent>
207
207
  <div className="space-y-3">
208
208
  {signals
209
- .filter(signal => signal.data.account_id === selectedAccountId)
209
+ .filter(signal => signal.data?.identity?.account_id === selectedAccountId)
210
210
  .slice(0, 5)
211
- .map((signal) => (
212
- <div key={signal.id} className="flex items-center justify-between p-3 border rounded">
213
- <div className="flex items-center space-x-3">
214
- <Badge className={getSignalTypeColor(signal.data.signal_type)}>
215
- {signal.data.signal_type}
216
- </Badge>
217
- <div>
218
- <div className="font-medium">{signal.title}</div>
219
- <div className="text-sm text-gray-500">
220
- {new Date(signal.data.occurred_at).toLocaleDateString()}
211
+ .map((signal) => {
212
+ const actionType = signal.data?.action?.action_type
213
+ const rating = signal.data?.scoring_components?.raw_score?.rating
214
+ const receivedAt = signal.data?.processing?.received_at || signal.created_at
215
+ return (
216
+ <div key={signal.id} className="flex items-center justify-between p-3 border rounded">
217
+ <div className="flex items-center space-x-3">
218
+ <Badge className={getSignalTypeColor(actionType || '')}>
219
+ {actionType || signal.title}
220
+ </Badge>
221
+ <div>
222
+ <div className="font-medium">{signal.title}</div>
223
+ <div className="text-sm text-gray-500">
224
+ {new Date(receivedAt).toLocaleDateString()}
225
+ </div>
221
226
  </div>
222
227
  </div>
228
+ {rating !== undefined && (
229
+ <div className={`font-bold ${
230
+ rating >= 4 ? 'text-green-600' : rating >= 3 ? 'text-yellow-600' : 'text-gray-500'
231
+ }`}>
232
+ {rating}/5
233
+ </div>
234
+ )}
223
235
  </div>
224
- <div className={`font-bold ${
225
- signal.data.score_delta > 0 ? 'text-green-600' : 'text-red-600'
226
- }`}>
227
- {signal.data.score_delta > 0 ? '+' : ''}{signal.data.score_delta}
228
- </div>
229
- </div>
230
- ))}
236
+ )
237
+ })}
231
238
  </div>
232
239
  </CardContent>
233
240
  </Card>
@@ -0,0 +1,26 @@
1
+ [
2
+ {
3
+ "name": "funnel-signal-mar",
4
+ "display_name": "Funnel Signal: Marketing",
5
+ "description": "Marketing-source funnel signal ingest — anonymous and identified visitors from the marketing site",
6
+ "integration_type": "webhook",
7
+ "is_active": true,
8
+ "is_configured": true,
9
+ "config": {
10
+ "handler": { "path": "funnel-signal" },
11
+ "description": "Marketing signal ingest via integration-routes"
12
+ }
13
+ },
14
+ {
15
+ "name": "funnel-signal-use",
16
+ "display_name": "Funnel Signal: Usage",
17
+ "description": "Usage-source funnel signal ingest — installed app instances reporting in-product signals",
18
+ "integration_type": "webhook",
19
+ "is_active": true,
20
+ "is_configured": true,
21
+ "config": {
22
+ "handler": { "path": "funnel-signal" },
23
+ "description": "Usage signal ingest via integration-routes"
24
+ }
25
+ }
26
+ ]
package/seed/types.json CHANGED
@@ -1,4 +1,63 @@
1
1
  [
2
+ {
3
+ "kind": "account",
4
+ "slug": "account",
5
+ "name": "Account",
6
+ "description": "Customer or prospect account with funnel intelligence tracking",
7
+ "icon": "building",
8
+ "color": "#3b82f6",
9
+ "ownership": "tenant",
10
+ "is_active": true,
11
+ "design_schema": {
12
+ "scope": "account",
13
+ "fields": {
14
+ "name": { "label": "Name", "system": true, "required": true, "data_type": "text", "permissions": { "system_admin": ["read", "write"], "support": ["read", "write"], "member": ["read"] } },
15
+ "slug": { "label": "Slug", "system": true, "required": false, "data_type": "text", "permissions": { "system_admin": ["read", "write"], "support": ["read"], "member": ["read"] } },
16
+ "is_active": { "label": "Active", "system": true, "required": true, "data_type": "boolean", "permissions": { "system_admin": ["read", "write"], "support": ["read", "write"], "member": ["read"] } },
17
+ "created_at": { "label": "Created", "system": true, "readonly": true, "required": false, "data_type": "datetime", "permissions": { "system_admin": ["read"], "support": ["read"], "member": ["read"] } },
18
+ "updated_at": { "label": "Updated", "system": true, "readonly": true, "required": false, "data_type": "datetime", "permissions": { "system_admin": ["read"], "support": ["read"], "member": ["read"] } },
19
+ "lifecycle_stage": { "label": "Lifecycle Stage", "system": false, "required": false, "data_type": "select", "options": [{"label":"Anonymous","value":"anonymous"},{"label":"Identified","value":"identified"},{"label":"Installed","value":"installed"},{"label":"Qualified","value":"qualified"},{"label":"Customer","value":"customer"},{"label":"Churned","value":"churned"}], "permissions": { "system_admin": ["read", "write"], "support": ["read", "write"], "member": ["read"] } },
20
+ "lead_score": { "label": "Lead Score", "system": false, "required": false, "data_type": "number", "validation": { "min": 0, "max": 25 }, "permissions": { "system_admin": ["read", "write"], "support": ["read", "write"], "member": ["read"] } },
21
+ "temperature": { "label": "Temperature", "system": false, "required": false, "data_type": "select", "options": [{"label":"Cold","value":"cold"},{"label":"Warm","value":"warm"},{"label":"Hot","value":"hot"},{"label":"On Fire","value":"on_fire"}], "permissions": { "system_admin": ["read", "write"], "support": ["read", "write"], "member": ["read"] } },
22
+ "last_signal_at": { "label": "Last Signal At", "system": false, "required": false, "data_type": "datetime", "permissions": { "system_admin": ["read", "write"], "support": ["read"], "member": ["read"] } },
23
+ "ratings": { "label": "Ratings", "system": false, "required": false, "data_type": "json", "permissions": { "system_admin": ["read", "write"], "support": ["read"], "member": ["read"] } },
24
+ "attribution": { "label": "Attribution", "system": false, "required": false, "data_type": "json", "permissions": { "system_admin": ["read", "write"], "support": ["read", "write"], "member": ["read"] } },
25
+ "claim_status": { "label": "Claim Status", "system": false, "required": false, "data_type": "select", "options": [{"label":"Unclaimed","value":"unclaimed"},{"label":"Claimed","value":"claimed"},{"label":"Verified","value":"verified"}], "permissions": { "system_admin": ["read", "write"], "support": ["read", "write"], "member": ["read"] } },
26
+ "claimed_apps": { "label": "Claimed Apps", "system": false, "required": false, "data_type": "json", "permissions": { "system_admin": ["read", "write"], "support": ["read"], "member": ["read"] } },
27
+ "segment": { "label": "Segment", "system": false, "required": false, "data_type": "text", "permissions": { "system_admin": ["read", "write"], "support": ["read", "write"], "member": ["read"] } },
28
+ "queue": { "label": "Queue", "system": false, "required": false, "data_type": "json", "permissions": { "system_admin": ["read", "write"], "support": ["read"], "member": ["read"] } }
29
+ },
30
+ "views": {
31
+ "default_list": {
32
+ "type": "list",
33
+ "display": "table",
34
+ "label": "Accounts",
35
+ "fields": {
36
+ "name": { "sortable": true, "display_type": "text" },
37
+ "lifecycle_stage": { "sortable": true, "display_type": "badge" },
38
+ "lead_score": { "sortable": true, "display_type": "number" },
39
+ "temperature": { "sortable": true, "display_type": "badge" },
40
+ "claim_status": { "sortable": true, "display_type": "badge" },
41
+ "last_signal_at": { "sortable": true, "display_type": "timestamp" }
42
+ },
43
+ "default_sort": { "field": "last_signal_at", "direction": "desc" }
44
+ },
45
+ "default_detail": {
46
+ "type": "detail",
47
+ "label": "Account",
48
+ "sections": [
49
+ { "title": "Identity", "fields": { "name": { "display_type": "input" }, "slug": { "display_type": "text" }, "is_active": { "display_type": "badge" } } },
50
+ { "title": "Funnel Intelligence", "fields": { "lifecycle_stage": { "display_type": "badge" }, "lead_score": { "display_type": "number" }, "temperature": { "display_type": "badge" }, "last_signal_at": { "display_type": "timestamp" }, "segment": { "display_type": "text" } } },
51
+ { "title": "Attribution", "fields": { "attribution": { "display_type": "json" } } },
52
+ { "title": "App Registrations", "fields": { "claim_status": { "display_type": "badge" }, "claimed_apps": { "display_type": "json" } } }
53
+ ]
54
+ }
55
+ },
56
+ "record_permissions": { "system_admin": ["create", "read", "update", "delete"], "support": ["create", "read", "update"], "member": ["read"] },
57
+ "functionality": null
58
+ },
59
+ "validation_schema": {}
60
+ },
2
61
  {
3
62
  "kind": "item",
4
63
  "slug": "case_analysis",