spine-framework-portal 0.2.16 → 0.2.18
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/functions/custom_anonymous-sessions.ts +342 -0
- package/functions/custom_claim-instance.ts +201 -0
- package/functions/custom_funnel-scoring.ts +256 -0
- package/functions/custom_funnel-signal.ts +717 -0
- package/functions/custom_funnel-timers.ts +435 -0
- package/functions/custom_portal-community-escalation.ts +65 -43
- package/functions/custom_stitch-identity.ts +210 -0
- package/functions/custom_support-triage.ts +517 -0
- package/package.json +1 -1
- package/pages/team/TeamPage.tsx +2 -2
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// Anonymous Session Functions
|
|
2
|
+
// Uses ONLY Spine APIs (ctx.db) - NO direct database access
|
|
3
|
+
// Handles stitch operation: anonymous session → identified account
|
|
4
|
+
|
|
5
|
+
import { createHandler } from './_shared/middleware'
|
|
6
|
+
import { calculateRecency, calculateRawScore } from './custom_funnel-scoring'
|
|
7
|
+
|
|
8
|
+
// Type IDs from migration
|
|
9
|
+
const TYPE_IDS = {
|
|
10
|
+
anonymous_session: '1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d',
|
|
11
|
+
funnel_signal: '0923f7a2-3ccd-4499-986f-28c6fd0597d9',
|
|
12
|
+
opportunity_queue: '2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const LINK_TYPE_IDS = {
|
|
16
|
+
account_signals: '4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a',
|
|
17
|
+
account_opportunities: '5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ============================================
|
|
21
|
+
// STITCH: Anonymous Session → Identified Account
|
|
22
|
+
// ============================================
|
|
23
|
+
|
|
24
|
+
export const stitchAnonymousToAccount = createHandler(async (ctx, body) => {
|
|
25
|
+
const { anonymous_id, person_id, account_id } = body
|
|
26
|
+
|
|
27
|
+
if (!anonymous_id || !person_id || !account_id) {
|
|
28
|
+
return { status: 'error', error: 'Missing required fields: anonymous_id, person_id, account_id' }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const now = new Date().toISOString()
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// 1. Get anonymous session using ctx.db
|
|
35
|
+
const { data: session, error: sessionError } = await ctx.db
|
|
36
|
+
.from('items')
|
|
37
|
+
.select('id, data')
|
|
38
|
+
.eq('type_id', TYPE_IDS.anonymous_session)
|
|
39
|
+
.eq('data->identity->>anonymous_id', anonymous_id)
|
|
40
|
+
.eq('is_active', true)
|
|
41
|
+
.order('created_at', { ascending: false })
|
|
42
|
+
.limit(1)
|
|
43
|
+
.single()
|
|
44
|
+
|
|
45
|
+
if (sessionError || !session) {
|
|
46
|
+
return { status: 'error', error: 'Anonymous session not found' }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const sessionData = session.data || {}
|
|
50
|
+
|
|
51
|
+
// Check if already stitched
|
|
52
|
+
if (sessionData.lifecycle?.stitched_at) {
|
|
53
|
+
return { status: 'error', error: 'Session already stitched' }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 2. Get account using ctx.db
|
|
57
|
+
const { data: account, error: accountError } = await ctx.db
|
|
58
|
+
.from('accounts')
|
|
59
|
+
.select('id, data')
|
|
60
|
+
.eq('id', account_id)
|
|
61
|
+
.single()
|
|
62
|
+
|
|
63
|
+
if (accountError || !account) {
|
|
64
|
+
return { status: 'error', error: 'Account not found' }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 3. Update all signals with account_id and person_id using ctx.db
|
|
68
|
+
const { error: signalsError } = await ctx.db
|
|
69
|
+
.from('items')
|
|
70
|
+
.update({
|
|
71
|
+
account_id: account_id,
|
|
72
|
+
'data->identity->>person_id': person_id,
|
|
73
|
+
'data->processing->>stitched_at': now,
|
|
74
|
+
'data->processing->>stitched_to_account_id': account_id,
|
|
75
|
+
updated_at: now
|
|
76
|
+
})
|
|
77
|
+
.eq('type_id', TYPE_IDS.funnel_signal)
|
|
78
|
+
.eq('data->identity->>anonymous_id', anonymous_id)
|
|
79
|
+
.is('account_id', null)
|
|
80
|
+
|
|
81
|
+
if (signalsError) {
|
|
82
|
+
console.error(`[Stitch] Failed to update signals: ${signalsError.message}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 4. Get updated signals for recalculation
|
|
86
|
+
const { data: updatedSignals } = await ctx.db
|
|
87
|
+
.from('items')
|
|
88
|
+
.select('data')
|
|
89
|
+
.eq('type_id', TYPE_IDS.funnel_signal)
|
|
90
|
+
.eq('account_id', account_id)
|
|
91
|
+
.eq('data->classification->>stage', 'identified')
|
|
92
|
+
.eq('is_active', true)
|
|
93
|
+
|
|
94
|
+
// 5. Recalculate identified rating with newly-stitched signals
|
|
95
|
+
let identifiedRating = { rating: 0, raw_score: 0, calculated_at: now, best_signal_id: null as string | null }
|
|
96
|
+
|
|
97
|
+
if (updatedSignals && updatedSignals.length > 0) {
|
|
98
|
+
let bestSignal = updatedSignals[0]
|
|
99
|
+
let bestScore = bestSignal.data?.scoring_components?.raw_score?.calculated || 0
|
|
100
|
+
|
|
101
|
+
for (const signal of updatedSignals) {
|
|
102
|
+
const score = signal.data?.scoring_components?.raw_score?.calculated || 0
|
|
103
|
+
if (score > bestScore) {
|
|
104
|
+
bestScore = score
|
|
105
|
+
bestSignal = signal
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Recalculate with current recency
|
|
110
|
+
const signalDate = new Date(bestSignal.data?.processing?.scored_at || now)
|
|
111
|
+
const recency = calculateRecency(signalDate, new Date(), 'identified')
|
|
112
|
+
|
|
113
|
+
if (recency.divisor) {
|
|
114
|
+
const newScore = calculateRawScore(
|
|
115
|
+
bestSignal.data?.action?.action_value || 1,
|
|
116
|
+
bestSignal.data?.scoring_components?.engagement?.type || 1,
|
|
117
|
+
recency.divisor
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
identifiedRating = {
|
|
121
|
+
rating: newScore.rating,
|
|
122
|
+
raw_score: newScore.calculated,
|
|
123
|
+
calculated_at: now,
|
|
124
|
+
best_signal_id: bestSignal.data?.id || null
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 6. Update account with stitched data using ctx.db
|
|
130
|
+
const currentFunnel = account.data?.funnel || {}
|
|
131
|
+
const anonymousRating = sessionData.scoring?.ratings?.anonymous
|
|
132
|
+
|
|
133
|
+
const updatedFunnel = {
|
|
134
|
+
...currentFunnel,
|
|
135
|
+
current_stage: 'identified',
|
|
136
|
+
ratings: {
|
|
137
|
+
...currentFunnel.ratings,
|
|
138
|
+
anonymous: anonymousRating ? {
|
|
139
|
+
...anonymousRating,
|
|
140
|
+
stitched_at: now,
|
|
141
|
+
archived: true
|
|
142
|
+
} : currentFunnel.ratings?.anonymous,
|
|
143
|
+
identified: identifiedRating
|
|
144
|
+
},
|
|
145
|
+
attribution: {
|
|
146
|
+
...currentFunnel.attribution,
|
|
147
|
+
anonymous_first_touch: sessionData.attribution?.first_touch
|
|
148
|
+
},
|
|
149
|
+
stage_history: [
|
|
150
|
+
...(currentFunnel.stage_history || []),
|
|
151
|
+
{ from: 'anonymous', to: 'identified', at: now }
|
|
152
|
+
]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await ctx.db
|
|
156
|
+
.from('accounts')
|
|
157
|
+
.update({
|
|
158
|
+
data: { ...account.data, funnel: updatedFunnel }
|
|
159
|
+
})
|
|
160
|
+
.eq('id', account_id)
|
|
161
|
+
|
|
162
|
+
// 7. Mark session as stitched using ctx.db
|
|
163
|
+
const updatedSessionData = {
|
|
164
|
+
...sessionData,
|
|
165
|
+
lifecycle: {
|
|
166
|
+
...sessionData.lifecycle,
|
|
167
|
+
stitched_at: now,
|
|
168
|
+
stitched_to_account_id: account_id,
|
|
169
|
+
stitched_to_person_id: person_id
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await ctx.db
|
|
174
|
+
.from('items')
|
|
175
|
+
.update({
|
|
176
|
+
data: updatedSessionData,
|
|
177
|
+
updated_at: now
|
|
178
|
+
})
|
|
179
|
+
.eq('id', session.id)
|
|
180
|
+
|
|
181
|
+
// 8. Check for immediate queue entry (strong anonymous activity)
|
|
182
|
+
let queueEntry = null
|
|
183
|
+
if (anonymousRating?.rating >= 4) {
|
|
184
|
+
const inference = { type: 'implementation', confidence: 'high' }
|
|
185
|
+
|
|
186
|
+
const queueData = {
|
|
187
|
+
identity: {
|
|
188
|
+
account_id: account_id,
|
|
189
|
+
person_id: person_id
|
|
190
|
+
},
|
|
191
|
+
trigger: {
|
|
192
|
+
source_signal_id: anonymousRating.best_signal_id,
|
|
193
|
+
trigger_stage: 'anonymous',
|
|
194
|
+
trigger_rating: anonymousRating.rating,
|
|
195
|
+
trigger_raw_score: anonymousRating.raw_score,
|
|
196
|
+
trigger_reason: 'High engagement during anonymous phase'
|
|
197
|
+
},
|
|
198
|
+
recommendation: {
|
|
199
|
+
opportunity_type: inference.type,
|
|
200
|
+
confidence: inference.confidence,
|
|
201
|
+
suggested_priority: anonymousRating.rating
|
|
202
|
+
},
|
|
203
|
+
review: {
|
|
204
|
+
status: 'pending',
|
|
205
|
+
reviewed_by: null,
|
|
206
|
+
reviewed_at: null,
|
|
207
|
+
conversion_opportunity_id: null
|
|
208
|
+
},
|
|
209
|
+
notes: {
|
|
210
|
+
reviewer_notes: null,
|
|
211
|
+
auto_reason: 'Stitched from anonymous session with high engagement'
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const { data: queueItem } = await ctx.db
|
|
216
|
+
.from('items')
|
|
217
|
+
.insert({
|
|
218
|
+
type_id: TYPE_IDS.opportunity_queue,
|
|
219
|
+
title: `${inference.type} - Stitched Session`,
|
|
220
|
+
account_id: account_id,
|
|
221
|
+
data: queueData
|
|
222
|
+
})
|
|
223
|
+
.select('id')
|
|
224
|
+
.single()
|
|
225
|
+
|
|
226
|
+
if (queueItem) {
|
|
227
|
+
queueEntry = { id: queueItem.id }
|
|
228
|
+
|
|
229
|
+
// Create link to account
|
|
230
|
+
await ctx.db
|
|
231
|
+
.from('links')
|
|
232
|
+
.insert({
|
|
233
|
+
link_type_id: LINK_TYPE_IDS.account_opportunities,
|
|
234
|
+
source_type: 'account',
|
|
235
|
+
source_id: account_id,
|
|
236
|
+
target_type: 'item',
|
|
237
|
+
target_id: queueItem.id
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// Update account queue reference
|
|
241
|
+
await ctx.db
|
|
242
|
+
.from('accounts')
|
|
243
|
+
.update({
|
|
244
|
+
data: {
|
|
245
|
+
...account.data,
|
|
246
|
+
funnel: {
|
|
247
|
+
...updatedFunnel,
|
|
248
|
+
queue: { pending_queue_entry_id: queueItem.id }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
.eq('id', account_id)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 9. Create links between account and all stitched signals
|
|
257
|
+
const { data: stitchedSignals } = await ctx.db
|
|
258
|
+
.from('items')
|
|
259
|
+
.select('id')
|
|
260
|
+
.eq('type_id', TYPE_IDS.funnel_signal)
|
|
261
|
+
.eq('account_id', account_id)
|
|
262
|
+
.eq('data->processing->>stitched_at', now)
|
|
263
|
+
|
|
264
|
+
for (const signal of stitchedSignals || []) {
|
|
265
|
+
await ctx.db
|
|
266
|
+
.from('links')
|
|
267
|
+
.insert({
|
|
268
|
+
link_type_id: LINK_TYPE_IDS.account_signals,
|
|
269
|
+
source_type: 'account',
|
|
270
|
+
source_id: account_id,
|
|
271
|
+
target_type: 'item',
|
|
272
|
+
target_id: signal.id,
|
|
273
|
+
data: { created_at: now, stitched: true }
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
status: 'success',
|
|
279
|
+
session_id: session.id,
|
|
280
|
+
account_id,
|
|
281
|
+
person_id,
|
|
282
|
+
stitched_signals: stitchedSignals?.length || 0,
|
|
283
|
+
queue_entry: queueEntry,
|
|
284
|
+
anonymous_rating: anonymousRating?.rating || 0,
|
|
285
|
+
identified_rating: identifiedRating.rating
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
} catch (err) {
|
|
289
|
+
console.error('[Stitch] Error:', err)
|
|
290
|
+
return { status: 'error', error: err instanceof Error ? err.message : 'Unknown error' }
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// ============================================
|
|
295
|
+
// GET ANONYMOUS SESSION DETAILS
|
|
296
|
+
// ============================================
|
|
297
|
+
|
|
298
|
+
export const getAnonymousSession = createHandler(async (ctx, body) => {
|
|
299
|
+
const { anonymous_id } = body
|
|
300
|
+
|
|
301
|
+
if (!anonymous_id) {
|
|
302
|
+
return { status: 'error', error: 'Missing anonymous_id' }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Get session using ctx.db
|
|
306
|
+
const { data: session, error } = await ctx.db
|
|
307
|
+
.from('items')
|
|
308
|
+
.select('id, data, created_at, updated_at')
|
|
309
|
+
.eq('type_id', TYPE_IDS.anonymous_session)
|
|
310
|
+
.eq('data->identity->>anonymous_id', anonymous_id)
|
|
311
|
+
.eq('is_active', true)
|
|
312
|
+
.order('created_at', { ascending: false })
|
|
313
|
+
.limit(1)
|
|
314
|
+
.single()
|
|
315
|
+
|
|
316
|
+
if (error || !session) {
|
|
317
|
+
return { status: 'error', error: 'Session not found' }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Get associated signals using ctx.db
|
|
321
|
+
const { data: signals } = await ctx.db
|
|
322
|
+
.from('items')
|
|
323
|
+
.select('id, data, created_at')
|
|
324
|
+
.eq('type_id', TYPE_IDS.funnel_signal)
|
|
325
|
+
.eq('data->identity->>anonymous_id', anonymous_id)
|
|
326
|
+
.eq('is_active', true)
|
|
327
|
+
.order('created_at', { ascending: false })
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
status: 'success',
|
|
331
|
+
session: {
|
|
332
|
+
id: session.id,
|
|
333
|
+
anonymous_id,
|
|
334
|
+
attribution: session.data?.attribution,
|
|
335
|
+
scoring: session.data?.scoring,
|
|
336
|
+
lifecycle: session.data?.lifecycle,
|
|
337
|
+
created_at: session.created_at,
|
|
338
|
+
updated_at: session.updated_at
|
|
339
|
+
},
|
|
340
|
+
signals: signals || []
|
|
341
|
+
}
|
|
342
|
+
})
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claim Instance Handler
|
|
3
|
+
* Validates install serial and generates JWT response for CLI claim flow
|
|
4
|
+
*
|
|
5
|
+
* Endpoint: POST /.netlify/functions/custom_claim-instance
|
|
6
|
+
* Body: { serial: string }
|
|
7
|
+
* Response: { response_code: string (JWT), expires_at: string }
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHandler, CoreContext } from './_shared/middleware'
|
|
11
|
+
import { adminDb } from './_shared/db'
|
|
12
|
+
import { resolveTypeId, resolveLinkTypeId } from './_shared/resolve-ids'
|
|
13
|
+
import { update } from './admin-data'
|
|
14
|
+
|
|
15
|
+
// @ts-ignore - jsonwebtoken types not available
|
|
16
|
+
import jwt from 'jsonwebtoken'
|
|
17
|
+
|
|
18
|
+
// Helper: call admin-data.update as a nested import (entity+id go in ctx.query)
|
|
19
|
+
function adminDataUpdate(ctx: CoreContext, entity: string, id: string, fields: Record<string, any>) {
|
|
20
|
+
return update({ ...ctx, query: { ...((ctx as any).query || {}), entity, id } } as any, fields)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Rate limiting (in-memory, per function instance)
|
|
24
|
+
const rateLimits = new Map<string, { count: number; resetAt: number }>()
|
|
25
|
+
|
|
26
|
+
function checkRateLimit(ip: string): boolean {
|
|
27
|
+
const now = Date.now()
|
|
28
|
+
const windowMs = 60 * 1000 // 1 minute
|
|
29
|
+
const maxRequests = 5
|
|
30
|
+
|
|
31
|
+
const current = rateLimits.get(ip)
|
|
32
|
+
|
|
33
|
+
if (!current || now > current.resetAt) {
|
|
34
|
+
rateLimits.set(ip, { count: 1, resetAt: now + windowMs })
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (current.count >= maxRequests) return false
|
|
39
|
+
current.count++
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const signClaimToken = (payload: any): string => {
|
|
44
|
+
const secret = process.env.PORTAL_CLAIM_SECRET
|
|
45
|
+
if (!secret) throw new Error('PORTAL_CLAIM_SECRET not configured')
|
|
46
|
+
return jwt.sign(payload, secret, { algorithm: 'HS256', expiresIn: '10m' })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const handler = createHandler(async (ctx, body) => {
|
|
50
|
+
const { serial } = body || {}
|
|
51
|
+
const personId = ctx.principal?.person_id
|
|
52
|
+
const accountId = ctx.principal?.account_id
|
|
53
|
+
const clientIp = (ctx as any).requestContext?.identity?.sourceIp || 'unknown'
|
|
54
|
+
|
|
55
|
+
if (!serial) {
|
|
56
|
+
const err: any = new Error('serial is required'); err.statusCode = 400; throw err
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!personId || !accountId) {
|
|
60
|
+
const err: any = new Error('Authentication required'); err.statusCode = 401; throw err
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!checkRateLimit(clientIp)) {
|
|
64
|
+
const err: any = new Error('Rate limit exceeded. Please try again in 1 minute.'); err.statusCode = 429; throw err
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!/^sf_inst_[a-z0-9-]+$/.test(serial)) {
|
|
68
|
+
const err: any = new Error('Invalid serial format'); err.statusCode = 400; throw err
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Resolve type and link IDs dynamically
|
|
72
|
+
const [spine_instance_type_id, funnel_signal_type_id, account_cli_instances_link_type_id] = await Promise.all([
|
|
73
|
+
resolveTypeId('item', 'spine_instance'),
|
|
74
|
+
resolveTypeId('item', 'funnel_signal'),
|
|
75
|
+
resolveLinkTypeId('account_cli_instances')
|
|
76
|
+
])
|
|
77
|
+
|
|
78
|
+
const now = new Date().toISOString()
|
|
79
|
+
|
|
80
|
+
// Lookup spine_instance by serial
|
|
81
|
+
const { data: instance, error: instanceError } = await adminDb
|
|
82
|
+
.from('items')
|
|
83
|
+
.select('id, account_id, data')
|
|
84
|
+
.eq('type_id', spine_instance_type_id)
|
|
85
|
+
.eq('data->>serial', serial)
|
|
86
|
+
.single()
|
|
87
|
+
|
|
88
|
+
if (instanceError || !instance) {
|
|
89
|
+
const err: any = new Error('Serial not found. Please install the package first.')
|
|
90
|
+
err.statusCode = 404; throw err
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (instance.account_id && instance.account_id !== accountId) {
|
|
94
|
+
const err: any = new Error('This installation is already claimed to another account')
|
|
95
|
+
err.statusCode = 409; throw err
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Update instance with claim info
|
|
99
|
+
const result = await adminDataUpdate(ctx, 'items', instance.id, {
|
|
100
|
+
account_id: accountId,
|
|
101
|
+
data: {
|
|
102
|
+
...instance.data,
|
|
103
|
+
claimed_by_person_id: personId,
|
|
104
|
+
claimed_at: now,
|
|
105
|
+
claimed_to_account_id: accountId
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
if (!result) {
|
|
110
|
+
const err: any = new Error('Failed to claim installation'); err.statusCode = 500; throw err
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Create link: account → instance (links has no design_schema — direct is fine)
|
|
114
|
+
const { error: linkError } = await adminDb
|
|
115
|
+
.from('links')
|
|
116
|
+
.upsert({
|
|
117
|
+
link_type_id: account_cli_instances_link_type_id,
|
|
118
|
+
source_type: 'account',
|
|
119
|
+
source_id: accountId,
|
|
120
|
+
target_type: 'item',
|
|
121
|
+
target_id: instance.id,
|
|
122
|
+
data: { created_at: now, claimed_by: personId }
|
|
123
|
+
}, { onConflict: 'link_type_id,source_id,target_id' })
|
|
124
|
+
|
|
125
|
+
if (linkError) {
|
|
126
|
+
console.error('[ClaimInstance] Failed to create link:', linkError)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Stitch existing signals to account (queued, one per record through admin-data)
|
|
130
|
+
const { data: signals } = await adminDb
|
|
131
|
+
.from('items')
|
|
132
|
+
.select('id, data')
|
|
133
|
+
.eq('type_id', funnel_signal_type_id)
|
|
134
|
+
.eq('data->>serial', serial)
|
|
135
|
+
.is('account_id', null)
|
|
136
|
+
|
|
137
|
+
if (signals && signals.length > 0) {
|
|
138
|
+
for (const signal of signals) {
|
|
139
|
+
await adminDataUpdate(
|
|
140
|
+
{ ...ctx, accountId },
|
|
141
|
+
'items',
|
|
142
|
+
signal.id,
|
|
143
|
+
{
|
|
144
|
+
account_id: accountId,
|
|
145
|
+
data: { ...signal.data, stitched_at: now }
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Update account data with instance count
|
|
152
|
+
const { data: account } = await adminDb
|
|
153
|
+
.from('accounts')
|
|
154
|
+
.select('data')
|
|
155
|
+
.eq('id', accountId)
|
|
156
|
+
.single()
|
|
157
|
+
|
|
158
|
+
if (account) {
|
|
159
|
+
await adminDataUpdate(ctx, 'accounts', accountId, {
|
|
160
|
+
data: {
|
|
161
|
+
...account.data,
|
|
162
|
+
cli_instance_count: (account.data?.cli_instance_count || 0) + 1,
|
|
163
|
+
last_cli_claim_at: now
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Generate JWT response
|
|
169
|
+
const expiresAt = Math.floor(Date.now() / 1000) + (10 * 60)
|
|
170
|
+
|
|
171
|
+
let responseCode: string
|
|
172
|
+
try {
|
|
173
|
+
responseCode = signClaimToken({
|
|
174
|
+
account_id: accountId,
|
|
175
|
+
person_id: personId,
|
|
176
|
+
serial_suffix: serial.slice(-4),
|
|
177
|
+
instance_id: instance.id,
|
|
178
|
+
iat: Math.floor(Date.now() / 1000),
|
|
179
|
+
exp: expiresAt
|
|
180
|
+
})
|
|
181
|
+
} catch (e) {
|
|
182
|
+
console.error('[ClaimInstance] JWT signing failed:', e)
|
|
183
|
+
const err: any = new Error('Failed to generate claim code'); err.statusCode = 500; throw err
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
status: 'success',
|
|
188
|
+
response_code: responseCode,
|
|
189
|
+
expires_at: new Date(expiresAt * 1000).toISOString(),
|
|
190
|
+
instance: {
|
|
191
|
+
serial: truncateSerial(serial),
|
|
192
|
+
app_slug: instance.data?.app_slug,
|
|
193
|
+
version: instance.data?.version
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
function truncateSerial(serial: string): string {
|
|
199
|
+
if (!serial || serial.length < 20) return serial
|
|
200
|
+
return `${serial.slice(0, 8)}***${serial.slice(-6)}`
|
|
201
|
+
}
|