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.
- package/CHANGELOG.md +20 -0
- package/components/CortexSidebar.tsx +27 -0
- package/functions/custom_funnel-signal.ts +290 -539
- package/functions/webhook-handlers.ts +1 -0
- package/index.tsx +10 -0
- package/package.json +1 -1
- package/pages/crm/AccountDetailPage.tsx +192 -146
- package/pages/intelligence/IntelligencePage.tsx +31 -24
- package/pages/ops/AuditFunnelPage.tsx +191 -0
- package/pages/ops/CommandCenterPage.tsx +377 -0
- package/pages/ops/InstallFunnelPage.tsx +226 -0
- package/seed/integrations.json +26 -0
- package/seed/pipelines.json +34 -5
- package/seed/triggers.json +44 -4
- package/seed/types.json +59 -0
|
@@ -1,55 +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
|
-
|
|
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
|
|
61
|
-
const
|
|
62
|
-
if (!
|
|
63
|
-
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' }
|
|
64
62
|
}
|
|
65
63
|
|
|
66
|
-
|
|
64
|
+
// 3. ENRICH — fetch prior signals for this identity
|
|
65
|
+
const priorSignals = await fetchPriorSignals(payload, signalTypeId)
|
|
67
66
|
|
|
68
|
-
//
|
|
69
|
-
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()
|
|
70
77
|
|
|
71
|
-
//
|
|
72
|
-
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
|
+
}
|
|
73
121
|
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
} else if (payload.anonymous_id) {
|
|
90
|
-
|
|
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
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
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:
|
|
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
|
-
//
|
|
202
|
+
// ID RESOLUTION HELPERS
|
|
173
203
|
// ============================================
|
|
174
204
|
|
|
175
|
-
async function
|
|
176
|
-
const
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
221
|
+
// ENRICHMENT
|
|
256
222
|
// ============================================
|
|
257
223
|
|
|
258
|
-
async function
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
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
|
-
//
|
|
241
|
+
// ACCOUNT UPDATE
|
|
341
242
|
// ============================================
|
|
342
243
|
|
|
343
244
|
async function updateAccountFunnel(
|
|
344
|
-
accountId: string,
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
): Promise<
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
284
|
+
// ANONYMOUS SESSION UPSERT
|
|
414
285
|
// ============================================
|
|
415
286
|
|
|
416
287
|
async function upsertAnonymousSession(
|
|
417
|
-
payload: SignalPayload,
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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',
|
|
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 (
|
|
437
|
-
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
535
|
-
|
|
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
|
-
//
|
|
355
|
+
// LINK CREATION
|
|
549
356
|
// ============================================
|
|
550
357
|
|
|
551
|
-
async function
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
//
|
|
371
|
+
// QUEUE ENTRY EVALUATION
|
|
570
372
|
// ============================================
|
|
571
373
|
|
|
572
374
|
async function evaluateQueueEntry(
|
|
573
|
-
payload: SignalPayload,
|
|
574
|
-
scoring:
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
//
|
|
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
|
}
|