spine-framework-cortex 0.1.13 → 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.
@@ -1,55 +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
-
24
- // IDs resolved at request time — never hardcoded
25
- async function resolveIds() {
26
- const [types, linkTypes, unidentifiedVisitorsAccountId] = await Promise.all([
27
- resolveTypeIds([
28
- { kind: 'item', slug: 'funnel_signal' },
29
- { kind: 'item', slug: 'anonymous_session' },
30
- { kind: 'item', slug: 'opportunity_queue' },
31
- ]),
32
- resolveLinkTypeIds(['account_signals', 'account_opportunities']),
33
- resolveAccountId('unidentified-visitors'),
34
- ])
35
- return {
36
- TYPE_IDS: {
37
- funnel_signal: types['item/funnel_signal'],
38
- anonymous_session: types['item/anonymous_session'],
39
- opportunity_queue: types['item/opportunity_queue'],
40
- },
41
- LINK_TYPE_IDS: {
42
- account_signals: linkTypes['account_signals'],
43
- account_opportunities: linkTypes['account_opportunities'],
44
- },
45
- UNIDENTIFIED_VISITORS_ACCOUNT_ID: unidentifiedVisitorsAccountId,
46
- }
47
- }
48
22
 
49
23
  // ============================================
50
24
  // SIGNAL HANDLER (Integration Routes Compatible)
51
25
  // ============================================
52
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
+
53
35
  export async function processSignal(
54
36
  sanitizedData: any,
55
37
  scriptContext: any,
@@ -57,53 +39,124 @@ export async function processSignal(
57
39
  ) {
58
40
  const receivedAt = new Date().toISOString()
59
41
 
60
- // 1. VALIDATE PAYLOAD
61
- const signal = validateSignalPayload(sanitizedData)
62
- if (!signal.valid) {
63
- return { status: 'error', error: (signal as { valid: false; error: string }).error }
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' }
64
62
  }
65
63
 
66
- const payload = signal.data
64
+ // 3. ENRICH — fetch prior signals for this identity
65
+ const priorSignals = await fetchPriorSignals(payload, signalTypeId)
67
66
 
68
- // Resolve IDs at request time — never hardcoded
69
- 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()
70
77
 
71
- // 2. ENRICH (using adminDb Spine APIs)
72
- 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
+ }
73
121
 
74
- // 3. SCORE
75
- 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()
76
132
 
77
- // 4. CREATE SIGNAL ITEM (adminDb.from('items').insert())
78
- 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
+ }
79
137
 
80
- // 5. UPDATE ACCOUNT OR CREATE ANONYMOUS SESSION
81
- let accountUpdate = null
82
- let sessionItem = null
138
+ const signalId = signalItem.id
83
139
 
140
+ // 6. UPDATE ACCOUNT or UPSERT ANONYMOUS SESSION
84
141
  if (payload.account_id) {
85
- accountUpdate = await updateAccountFunnel(payload.account_id, payload.stage, scoring)
86
-
87
- // Create link between account and signal
88
- await createAccountSignalLink(payload.account_id, signalItem.id, ids)
89
- } else if (payload.anonymous_id) {
90
- sessionItem = await upsertAnonymousSession(payload, enrichment, scoring, signalItem.id, ids)
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)
91
148
  }
92
149
 
93
- // 6. EVALUATE QUEUE (if rating >= 4)
94
- let queueEntry = null
95
- if (scoring.rating >= 4) {
96
- queueEntry = await evaluateQueueEntry(payload, scoring, signalItem.id, ids)
150
+ // 7. EVALUATE QUEUE if rating >= 4
151
+ if (scoring.rating >= 4 && queueTypeId) {
152
+ await evaluateQueueEntry(payload, queueTypeId, opportunityLinkTypeId, signalId, scoring, scoredAt)
97
153
  }
98
154
 
99
155
  return {
100
156
  status: 'success',
101
- signal_id: signalItem.id,
157
+ signal_id: signalId,
102
158
  rating: scoring.rating,
103
159
  raw_score: scoring.calculated,
104
- account_updated: !!accountUpdate,
105
- session_created: !!sessionItem,
106
- queue_entry: queueEntry
107
160
  }
108
161
  }
109
162
 
@@ -111,41 +164,6 @@ export async function processSignal(
111
164
  // VALIDATION
112
165
  // ============================================
113
166
 
114
- function validateSignalPayload(body: any): { valid: true; data: SignalPayload } | { valid: false; error: string } {
115
- if (!body) {
116
- return { valid: false, error: 'Missing request body' }
117
- }
118
-
119
- // Check required fields
120
- if (!body.stage || !['anonymous', 'identified', 'installed'].includes(body.stage)) {
121
- return { valid: false, error: 'Invalid or missing stage' }
122
- }
123
-
124
- if (!body.source || !['mar', 'int', 'use', 'manual'].includes(body.source)) {
125
- return { valid: false, error: 'Invalid or missing source' }
126
- }
127
-
128
- if (!body.action_type) {
129
- return { valid: false, error: 'Missing action_type' }
130
- }
131
-
132
- if (!body.action_value || ![1, 2, 5].includes(body.action_value)) {
133
- return { valid: false, error: 'Invalid action_value (must be 1, 2, or 5)' }
134
- }
135
-
136
- // Check identity - must have at least one
137
- if (!body.anonymous_id && !body.person_id && !body.account_id) {
138
- return { valid: false, error: 'Must provide anonymous_id, person_id, or account_id' }
139
- }
140
-
141
- // For 'mar' source, session_id is required
142
- if (body.source === 'mar' && !body.session_id) {
143
- return { valid: false, error: 'session_id required for marketing signals' }
144
- }
145
-
146
- return { valid: true, data: body as SignalPayload }
147
- }
148
-
149
167
  interface SignalPayload {
150
168
  anonymous_id?: string
151
169
  person_id?: string
@@ -166,513 +184,246 @@ interface SignalPayload {
166
184
  utm_campaign?: string
167
185
  instance_id?: string
168
186
  environment?: 'dev' | 'staging' | 'production'
187
+ pipeline_slug?: string
188
+ }
189
+
190
+ function validateSignalPayload(body: any): { valid: true; data: SignalPayload } | { valid: false; error: string } {
191
+ if (!body) return { valid: false, error: 'Missing request body' }
192
+ if (!body.stage || !['anonymous', 'identified', 'installed'].includes(body.stage)) return { valid: false, error: 'Invalid or missing stage' }
193
+ if (!body.source || !['mar', 'int', 'use', 'manual'].includes(body.source)) return { valid: false, error: 'Invalid or missing source' }
194
+ if (!body.action_type) return { valid: false, error: 'Missing action_type' }
195
+ if (!body.action_value || ![1, 2, 5].includes(body.action_value)) return { valid: false, error: 'Invalid action_value (must be 1, 2, or 5)' }
196
+ if (!body.anonymous_id && !body.person_id && !body.account_id) return { valid: false, error: 'Must provide anonymous_id, person_id, or account_id' }
197
+ if (body.source === 'mar' && !body.session_id) return { valid: false, error: 'session_id required for marketing signals' }
198
+ return { valid: true, data: body as SignalPayload }
169
199
  }
170
200
 
171
201
  // ============================================
172
- // ENRICHMENT (using adminDb Spine APIs)
202
+ // ID RESOLUTION HELPERS
173
203
  // ============================================
174
204
 
175
- async function enrichSignal(payload: SignalPayload, ids: Awaited<ReturnType<typeof resolveIds>>): Promise<EnrichmentResult> {
176
- const occurredAt = payload.occurred_at ? new Date(payload.occurred_at) : new Date()
177
-
178
- // Query prior signals for engagement calculation
179
- let priorSignals: any[] = []
180
-
181
- if (payload.anonymous_id) {
182
- // Query by anonymous_id using adminDb
183
- const { data } = await adminDb
184
- .from('items')
185
- .select('data->>session_id as session_id, data->processing->>scored_at as occurred_at')
186
- .eq('type_id', ids.TYPE_IDS.funnel_signal)
187
- .eq('data->identity->>anonymous_id', payload.anonymous_id)
188
- .order('created_at', { ascending: true })
189
- .limit(100)
190
-
191
- priorSignals = (data as any[]) || []
192
- } else if (payload.account_id) {
193
- // Query by account_id using adminDb
194
- const { data } = await adminDb
195
- .from('items')
196
- .select('data->>session_id as session_id, data->processing->>scored_at as occurred_at')
197
- .eq('type_id', ids.TYPE_IDS.funnel_signal)
198
- .eq('account_id', payload.account_id)
199
- .order('created_at', { ascending: true })
200
- .limit(100)
201
-
202
- priorSignals = (data as any[]) || []
203
- }
204
-
205
- // Calculate engagement
206
- const engagement = calculateEngagement(
207
- priorSignals,
208
- payload.session_id || 'default',
209
- occurredAt.toISOString(),
210
- payload.stage
211
- )
212
-
213
- // Calculate recency
214
- const recency = calculateRecency(occurredAt, new Date(), payload.stage)
215
-
216
- // Extract referrer
217
- const referrerDomain = extractDomain(payload.referrer)
218
- const referrerCategory = categorizeReferrer(referrerDomain)
219
-
220
- return {
221
- engagement,
222
- recency,
223
- referrer_domain: referrerDomain,
224
- referrer_category: referrerCategory,
225
- occurred_at: occurredAt.toISOString()
226
- }
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
227
208
  }
228
209
 
229
- interface EnrichmentResult {
230
- engagement: EngagementResult
231
- recency: RecencyResult
232
- referrer_domain: string
233
- referrer_category: string
234
- 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
235
213
  }
236
214
 
237
- // ============================================
238
- // SCORING
239
- // ============================================
240
-
241
- function scoreSignal(payload: SignalPayload, enrichment: EnrichmentResult): RawScoreResult {
242
- if (enrichment.recency.divisor === null) {
243
- // Expired signal gets minimum score
244
- return { calculated: 0, max_possible: 25, rating: 1 }
245
- }
246
-
247
- return calculateRawScore(
248
- payload.action_value,
249
- enrichment.engagement.type,
250
- enrichment.recency.divisor
251
- )
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
252
218
  }
253
219
 
254
220
  // ============================================
255
- // CREATE SIGNAL ITEM (adminDb.from('items').insert())
221
+ // ENRICHMENT
256
222
  // ============================================
257
223
 
258
- async function createSignalItem(
259
- payload: SignalPayload,
260
- enrichment: EnrichmentResult,
261
- scoring: RawScoreResult,
262
- receivedAt: string,
263
- ids: Awaited<ReturnType<typeof resolveIds>>
264
- ): Promise<{ id: string }> {
265
- const scoredAt = new Date().toISOString()
266
-
267
- const signalData = {
268
- identity: {
269
- anonymous_id: payload.anonymous_id || null,
270
- person_id: payload.person_id || null,
271
- account_id: payload.account_id || null,
272
- session_id: payload.session_id || null
273
- },
274
- classification: {
275
- stage: payload.stage,
276
- source: payload.source
277
- },
278
- action: {
279
- action_type: payload.action_type,
280
- action_value: payload.action_value,
281
- action_description: payload.action_description || null
282
- },
283
- scoring_components: {
284
- engagement: {
285
- type: enrichment.engagement.type,
286
- context: enrichment.engagement.context,
287
- session_depth: enrichment.engagement.session_depth,
288
- prior_session_count: enrichment.engagement.prior_session_count || 0
289
- },
290
- recency: {
291
- divisor: enrichment.recency.divisor,
292
- age_days: enrichment.recency.age_days,
293
- window: enrichment.recency.window
294
- },
295
- raw_score: {
296
- calculated: scoring.calculated,
297
- max_possible: scoring.max_possible,
298
- rating: scoring.rating
299
- }
300
- },
301
- attribution: {
302
- first_touch_referrer_domain: enrichment.referrer_domain,
303
- immediate_referrer: payload.referrer || null,
304
- utm_source: payload.utm_source || null,
305
- utm_medium: payload.utm_medium || null,
306
- utm_campaign: payload.utm_campaign || null
307
- },
308
- processing: {
309
- received_at: receivedAt,
310
- enriched_at: scoredAt,
311
- scored_at: scoredAt,
312
- stitched_at: null,
313
- stitched_to_account_id: null
314
- },
315
- source_metadata: {
316
- instance_id: payload.instance_id || null,
317
- environment: payload.environment || null
318
- }
319
- }
320
-
321
- const { data, error } = await adminDb
322
- .from('items')
323
- .insert({
324
- type_id: ids.TYPE_IDS.funnel_signal,
325
- title: `${payload.action_type} - ${payload.action_value}`,
326
- account_id: payload.account_id || ids.UNIDENTIFIED_VISITORS_ACCOUNT_ID,
327
- data: signalData
328
- })
329
- .select('id')
330
- .single()
331
-
332
- if (error) {
333
- throw new Error(`Failed to create signal: ${error.message}`)
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 []
334
232
  }
335
-
336
- 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
+ }))
337
238
  }
338
239
 
339
240
  // ============================================
340
- // UPDATE ACCOUNT FUNNEL (adminDb.from('accounts').update())
241
+ // ACCOUNT UPDATE
341
242
  // ============================================
342
243
 
343
244
  async function updateAccountFunnel(
344
- accountId: string,
345
- stage: string,
346
- scoring: RawScoreResult
347
- ): Promise<boolean> {
348
- // Get current account data
349
- const { data: account, error: fetchError } = await adminDb
350
- .from('accounts')
351
- .select('data')
352
- .eq('id', accountId)
353
- .single()
354
-
355
- if (fetchError || !account) {
356
- console.error(`[FunnelSignal] Account not found: ${accountId}`)
357
- return false
358
- }
359
-
360
- const now = new Date().toISOString()
361
-
362
- // Only update if this is the best signal for this stage
363
- const currentStageRating = account.data?.ratings?.[stage]?.rating || 0
364
- const shouldUpdate = scoring.rating > currentStageRating
365
-
366
- if (!shouldUpdate) {
367
- // Just update last_signal_at
368
- await adminDb
369
- .from('accounts')
370
- .update({
371
- data: { ...account.data, last_signal_at: now }
372
- })
373
- .eq('id', accountId)
374
-
375
- return true
376
- }
377
-
378
- // Update rating and temperature — write flat to data so UI can read directly
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 || {}
379
253
  const temperature = ratingToTemperature(scoring.rating)
380
- const updatedRatings = {
381
- ...(account.data?.ratings || {}),
382
- [stage]: {
383
- rating: scoring.rating,
384
- raw_score: scoring.calculated,
385
- calculated_at: now
386
- }
254
+ const ratingEntry = { rating: scoring.rating, raw_score: scoring.calculated, calculated_at: now }
255
+
256
+ const updatedRatings: Record<string, any> = { ...(currentData.ratings || {}) }
257
+ const currentStageRating = updatedRatings[stage]?.rating || 0
258
+ if (scoring.rating > currentStageRating) updatedRatings[stage] = ratingEntry
259
+ if (pipelineSlug) {
260
+ const currentPipelineRating = updatedRatings[pipelineSlug]?.rating || 0
261
+ if (scoring.rating >= currentPipelineRating) updatedRatings[pipelineSlug] = ratingEntry
387
262
  }
388
263
 
389
- const { error } = await adminDb
390
- .from('accounts')
391
- .update({
392
- data: {
393
- ...account.data,
394
- lifecycle_stage: stage,
395
- lead_score: scoring.calculated,
396
- temperature,
397
- last_signal_at: now,
398
- ratings: updatedRatings,
399
- attribution: account.data?.attribution || null
400
- }
401
- })
402
- .eq('id', accountId)
403
-
404
- if (error) {
405
- console.error(`[FunnelSignal] Failed to update account: ${error.message}`)
406
- return false
264
+ const attribution = currentData.attribution || {
265
+ first_touch_referrer_domain: referrerDomain,
266
+ first_touch_referrer_category: referrerCategory,
267
+ first_touch_occurred_at: now,
407
268
  }
408
269
 
409
- 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)
410
281
  }
411
282
 
412
283
  // ============================================
413
- // UPSERT ANONYMOUS SESSION (adminDb.from('items').insert() / .update())
284
+ // ANONYMOUS SESSION UPSERT
414
285
  // ============================================
415
286
 
416
287
  async function upsertAnonymousSession(
417
- payload: SignalPayload,
418
- enrichment: EnrichmentResult,
419
- scoring: RawScoreResult,
420
- signalId: string,
421
- ids: Awaited<ReturnType<typeof resolveIds>>
422
- ): Promise<{ id: string; created: boolean }> {
423
- const now = new Date().toISOString()
424
-
425
- // Try to find existing session
426
- const { data: existingSession } = await adminDb
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
427
293
  .from('items')
428
294
  .select('id, data')
429
- .eq('type_id', ids.TYPE_IDS.anonymous_session)
430
- .eq('data->identity->>anonymous_id', payload.anonymous_id!)
431
- .eq('is_active', true)
295
+ .eq('type_id', sessionTypeId)
296
+ .eq('data->identity->>anonymous_id' as any, payload.anonymous_id!)
432
297
  .order('created_at', { ascending: false })
433
298
  .limit(1)
434
299
  .maybeSingle()
435
300
 
436
- if (existingSession) {
437
- // Update existing session
438
- const currentData = existingSession.data || {}
439
- const currentRatings = currentData.scoring?.ratings || {}
440
-
441
- const shouldUpdateRating = !currentRatings.anonymous || scoring.rating > currentRatings.anonymous.rating
442
-
443
- const updatedData = {
444
- ...currentData,
445
- attribution: {
446
- ...currentData.attribution,
447
- current_referrer: {
448
- referrer_domain: enrichment.referrer_domain,
449
- referrer_url: payload.referrer || null,
450
- occurred_at: now
451
- }
452
- },
453
- scoring: {
454
- ...currentData.scoring,
455
- ratings: {
456
- anonymous: shouldUpdateRating ? {
457
- rating: scoring.rating,
458
- raw_score: scoring.calculated,
459
- calculated_at: now,
460
- best_signal_id: signalId,
461
- signal_count: (currentRatings.anonymous?.signal_count || 0) + 1
462
- } : currentRatings.anonymous
463
- },
464
- temperature: shouldUpdateRating ? ratingToTemperature(scoring.rating) : currentData.scoring?.temperature
465
- },
466
- lifecycle: {
467
- ...currentData.lifecycle,
468
- last_activity_at: now
469
- }
470
- }
471
-
472
- await adminDb
473
- .from('items')
474
- .update({ data: updatedData, updated_at: now })
475
- .eq('id', existingSession.id)
476
-
477
- return { id: existingSession.id, created: false }
478
- }
479
-
480
- // Create new session
481
- const sessionData = {
482
- identity: {
483
- anonymous_id: payload.anonymous_id
484
- },
485
- attribution: {
486
- first_touch: {
487
- referrer_domain: enrichment.referrer_domain,
488
- referrer_url: payload.referrer || null,
489
- referrer_category: enrichment.referrer_category,
490
- landing_page: payload.url || null,
491
- landing_page_category: null,
492
- occurred_at: now,
493
- utm_source: payload.utm_source || null,
494
- utm_medium: payload.utm_medium || null,
495
- utm_campaign: payload.utm_campaign || null
496
- },
497
- current_referrer: {
498
- referrer_domain: enrichment.referrer_domain,
499
- referrer_url: payload.referrer || null,
500
- occurred_at: now
501
- }
502
- },
503
- scoring: {
504
- ratings: {
505
- anonymous: {
506
- rating: scoring.rating,
507
- raw_score: scoring.calculated,
508
- calculated_at: now,
509
- best_signal_id: signalId,
510
- signal_count: 1
511
- }
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
+ } : {}),
512
319
  },
513
- current_stage: 'anonymous',
514
- temperature: ratingToTemperature(scoring.rating)
515
- },
516
- lifecycle: {
517
- created_at: now,
518
- last_activity_at: now,
519
- stitched_at: null,
520
- stitched_to_account_id: null,
521
- stitched_to_person_id: null
522
- },
523
- retention: {
524
- retention_days: 90,
525
- purge_after: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString()
526
- }
527
- }
528
-
529
- const { data, error } = await adminDb
530
- .from('items')
531
- .insert({
532
- type_id: ids.TYPE_IDS.anonymous_session,
320
+ }).eq('id', existing.id)
321
+ } else {
322
+ await adminDb.from('items').insert({
323
+ type_id: sessionTypeId,
533
324
  title: `Anonymous: ${payload.anonymous_id!.slice(0, 8)}`,
534
- account_id: payload.account_id || ids.UNIDENTIFIED_VISITORS_ACCOUNT_ID,
535
- 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
+ },
536
350
  })
537
- .select('id')
538
- .single()
539
-
540
- if (error) {
541
- throw new Error(`Failed to create anonymous session: ${error.message}`)
542
351
  }
543
-
544
- return { id: data.id, created: true }
545
352
  }
546
353
 
547
354
  // ============================================
548
- // CREATE ACCOUNT-SIGNAL LINK (adminDb.from('links').insert())
355
+ // LINK CREATION
549
356
  // ============================================
550
357
 
551
- async function createAccountSignalLink(accountId: string, signalId: string, ids: Awaited<ReturnType<typeof resolveIds>>): Promise<void> {
552
- const { error } = await adminDb
553
- .from('links')
554
- .insert({
555
- link_type_id: ids.LINK_TYPE_IDS.account_signals,
556
- source_type: 'account',
557
- source_id: accountId,
558
- target_type: 'item',
559
- target_id: signalId,
560
- data: { created_at: new Date().toISOString() }
561
- })
562
-
563
- if (error) {
564
- console.error(`[FunnelSignal] Failed to create link: ${error.message}`)
565
- }
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))
566
368
  }
567
369
 
568
370
  // ============================================
569
- // EVALUATE QUEUE ENTRY
371
+ // QUEUE ENTRY EVALUATION
570
372
  // ============================================
571
373
 
572
374
  async function evaluateQueueEntry(
573
- payload: SignalPayload,
574
- scoring: RawScoreResult,
575
- signalId: string,
576
- ids: Awaited<ReturnType<typeof resolveIds>>
577
- ): Promise<{ id: string } | null> {
578
- // Infer opportunity type
579
- const inference = inferOpportunityType([{ action: payload }], payload.stage, scoring.rating)
580
-
581
- const now = new Date().toISOString()
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
+ )
582
382
 
583
- const queueData = {
584
- identity: {
585
- account_id: payload.account_id || null,
586
- person_id: payload.person_id || null
587
- },
588
- trigger: {
589
- source_signal_id: signalId,
590
- trigger_stage: payload.stage,
591
- trigger_rating: scoring.rating,
592
- trigger_raw_score: scoring.calculated,
593
- trigger_reason: `High engagement: ${inference.type}`
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}`,
594
399
  },
595
- recommendation: {
596
- opportunity_type: inference.type,
597
- confidence: inference.confidence,
598
- suggested_priority: Math.min(scoring.rating, 5)
599
- },
600
- review: {
601
- status: 'pending',
602
- reviewed_by: null,
603
- reviewed_at: null,
604
- conversion_opportunity_id: null
605
- },
606
- notes: {
607
- reviewer_notes: null,
608
- auto_reason: `Auto-generated: ${inference.type} opportunity detected with confidence ${inference.confidence}`
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)
609
411
  }
610
412
  }
611
-
612
- const { data, error } = await adminDb
613
- .from('items')
614
- .insert({
615
- type_id: ids.TYPE_IDS.opportunity_queue,
616
- title: `${inference.type} - ${inference.confidence} priority`,
617
- account_id: payload.account_id || ids.UNIDENTIFIED_VISITORS_ACCOUNT_ID,
618
- data: queueData
619
- })
620
- .select('id')
621
- .single()
622
-
623
- if (error) {
624
- console.error(`[FunnelSignal] Failed to create queue entry: ${error.message}`)
625
- return null
626
- }
627
-
628
- // If we have an account, update the queue reference and create link
629
- if (payload.account_id) {
630
- const { data: acct } = await adminDb
631
- .from('accounts')
632
- .select('data')
633
- .eq('id', payload.account_id)
634
- .single()
635
-
636
- await adminDb
637
- .from('accounts')
638
- .update({
639
- data: {
640
- ...(acct?.data || {}),
641
- queue: { pending_opportunity_id: data.id }
642
- }
643
- })
644
- .eq('id', payload.account_id)
645
-
646
- await adminDb
647
- .from('links')
648
- .insert({
649
- link_type_id: ids.LINK_TYPE_IDS.account_opportunities,
650
- source_type: 'account',
651
- source_id: payload.account_id,
652
- target_type: 'item',
653
- target_id: data.id
654
- })
655
- }
656
-
657
- return { id: data.id }
658
413
  }
659
414
 
660
415
  // ============================================
661
- // UTILITY FUNCTIONS
416
+ // UTILITIES
662
417
  // ============================================
663
418
 
664
419
  function extractDomain(url: string | undefined): string {
665
420
  if (!url) return 'direct'
666
- try {
667
- const urlObj = new URL(url)
668
- return urlObj.hostname.replace(/^www\./, '')
669
- } catch {
670
- return url
671
- }
421
+ try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
672
422
  }
673
423
 
674
- function ratingToTemperature(rating: number): 'cold' | 'warm' | 'hot' {
424
+ function ratingToTemperature(rating: number): 'cold' | 'warm' | 'hot' | 'on_fire' {
675
425
  if (rating <= 2) return 'cold'
676
426
  if (rating <= 3) return 'warm'
677
- return 'hot'
427
+ if (rating <= 4) return 'hot'
428
+ return 'on_fire'
678
429
  }