spine-framework-cortex 0.1.14 → 0.1.16
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 +10 -0
- package/functions/custom_funnel-signal.ts +286 -552
- package/functions/webhook-handlers.ts +1 -0
- package/package.json +1 -1
- package/pages/intelligence/IntelligencePage.tsx +31 -24
- package/seed/integrations.json +24 -0
- package/seed/types.json +59 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [0.1.16] — 2026-06-09
|
|
8
|
+
- Fixed `integrations.json` seed — removed non-existent `description` column, changed `display_name` to `name`, added required `slug`, `provider`, `status` fields
|
|
9
|
+
|
|
10
|
+
## [0.1.15] — 2026-06-09
|
|
11
|
+
- Fixed `integrations.json` handler config format for `funnel-signal-mar` and `funnel-signal-use` integrations
|
|
12
|
+
- Removed orphaned `on_funnel_signal_created` trigger from `triggers.json` — trigger engine requires `pipeline_id` and cannot directly invoke custom functions
|
|
13
|
+
- Removed orphaned `funnel-scoring-trigger` handler registration from `webhook-handlers.ts`
|
|
14
|
+
- Deleted orphaned `custom_funnel-scoring-trigger.ts` file
|
|
15
|
+
- Restored `custom_funnel-signal.ts` as complete inline handler with full scoring, account updates, link creation, and queue evaluation
|
|
16
|
+
|
|
7
17
|
## [0.1.14] — 2026-06-09
|
|
8
18
|
- Added **Command Center** page (`/ops/command-center`) — KPI strip, Hot Opportunities table, Audit/Install funnel tab strip, funnel health panels
|
|
9
19
|
- 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
|
-
//
|
|
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
|
|
62
|
-
const
|
|
63
|
-
if (!
|
|
64
|
-
return { status: 'error', 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
|
-
|
|
64
|
+
// 3. ENRICH — fetch prior signals for this identity
|
|
65
|
+
const priorSignals = await fetchPriorSignals(payload, signalTypeId)
|
|
68
66
|
|
|
69
|
-
//
|
|
70
|
-
const
|
|
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
|
-
//
|
|
73
|
-
const
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
} else if (payload.anonymous_id) {
|
|
91
|
-
|
|
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.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
accountId: payload.account_id || ids.UNIDENTIFIED_VISITORS_ACCOUNT_ID,
|
|
104
|
-
db: adminDb,
|
|
105
|
-
requestId: scriptContext?.requestId || crypto.randomUUID(),
|
|
150
|
+
// 7. EVALUATE QUEUE — if 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:
|
|
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
|
-
//
|
|
202
|
+
// ID RESOLUTION HELPERS
|
|
184
203
|
// ============================================
|
|
185
204
|
|
|
186
|
-
async function
|
|
187
|
-
const
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
221
|
+
// ENRICHMENT
|
|
267
222
|
// ============================================
|
|
268
223
|
|
|
269
|
-
async function
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
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
|
-
//
|
|
241
|
+
// ACCOUNT UPDATE
|
|
353
242
|
// ============================================
|
|
354
243
|
|
|
355
244
|
async function updateAccountFunnel(
|
|
356
|
-
accountId: string,
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
284
|
+
// ANONYMOUS SESSION UPSERT
|
|
431
285
|
// ============================================
|
|
432
286
|
|
|
433
287
|
async function upsertAnonymousSession(
|
|
434
|
-
payload: SignalPayload,
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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',
|
|
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 (
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
552
|
-
|
|
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
|
-
//
|
|
355
|
+
// LINK CREATION
|
|
566
356
|
// ============================================
|
|
567
357
|
|
|
568
|
-
async function
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
//
|
|
371
|
+
// QUEUE ENTRY EVALUATION
|
|
587
372
|
// ============================================
|
|
588
373
|
|
|
589
374
|
async function evaluateQueueEntry(
|
|
590
|
-
payload: SignalPayload,
|
|
591
|
-
scoring:
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
//
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -11,11 +11,11 @@ interface FunnelSignal {
|
|
|
11
11
|
id: string
|
|
12
12
|
title: string
|
|
13
13
|
data: {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
209
|
+
.filter(signal => signal.data?.identity?.account_id === selectedAccountId)
|
|
210
210
|
.slice(0, 5)
|
|
211
|
-
.map((signal) =>
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
<div>
|
|
218
|
-
<
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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,24 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "Funnel Signal: Marketing",
|
|
4
|
+
"slug": "funnel-signal-mar",
|
|
5
|
+
"provider": "webhook",
|
|
6
|
+
"status": "active",
|
|
7
|
+
"is_active": true,
|
|
8
|
+
"config": {
|
|
9
|
+
"handler": { "path": "funnel-signal" },
|
|
10
|
+
"description": "Marketing signal ingest via integration-routes"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "Funnel Signal: Usage",
|
|
15
|
+
"slug": "funnel-signal-use",
|
|
16
|
+
"provider": "webhook",
|
|
17
|
+
"status": "active",
|
|
18
|
+
"is_active": true,
|
|
19
|
+
"config": {
|
|
20
|
+
"handler": { "path": "funnel-signal" },
|
|
21
|
+
"description": "Usage signal ingest via integration-routes"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
]
|
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",
|