spine-framework-cortex 0.2.19 → 0.2.21

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.
@@ -0,0 +1,314 @@
1
+ import { createHandler } from './_shared/middleware'
2
+ import { adminDb } from './_shared/db'
3
+
4
+ /**
5
+ * Custom Tag Management Handler
6
+ *
7
+ * Actions:
8
+ * - POST ?action=create_or_get_tag - Get existing tag or create new one
9
+ * - POST ?action=list_tags - List tags with filtering
10
+ * - POST ?action=update_tag_usage - Increment tag usage count
11
+ * - POST ?action=merge_tags - Merge duplicate tags
12
+ *
13
+ * Provides centralized tag management for case analysis and other systems.
14
+ */
15
+
16
+ // ─── CONSTANTS ────────────────────────────────────────────────────────────────
17
+
18
+ const TAG_TYPE_ID = 'tag' // Will be looked up by slug
19
+
20
+ // ─── TYPES ────────────────────────────────────────────────────────────────────
21
+
22
+ interface TagRequest {
23
+ slug: string
24
+ name: string
25
+ purpose?: string
26
+ category: 'bug_classification' | 'knowledge_value' | 'process_type' | 'sentiment'
27
+ applicable_to?: string[]
28
+ }
29
+
30
+ interface TagResponse {
31
+ id: string
32
+ slug: string
33
+ name: string
34
+ purpose?: string
35
+ category: string
36
+ applicable_to: string[]
37
+ usage_count: number
38
+ created_at: string
39
+ updated_at: string
40
+ }
41
+
42
+ interface ListTagsRequest {
43
+ category?: string
44
+ applicable_to?: string
45
+ limit?: number
46
+ offset?: number
47
+ }
48
+
49
+ // ─── HELPERS ──────────────────────────────────────────────────────────────────
50
+
51
+ async function getTagTypeId(): Promise<string> {
52
+ const { data: tagType } = await adminDb
53
+ .from('types')
54
+ .select('id')
55
+ .eq('slug', 'tag')
56
+ .single()
57
+
58
+ if (!tagType) throw new Error('Tag type not found')
59
+ return tagType.id
60
+ }
61
+
62
+ async function findExistingTag(slug: string): Promise<TagResponse | null> {
63
+ const { data: tag } = await adminDb
64
+ .from('items')
65
+ .select('*')
66
+ .eq('type_id', await getTagTypeId())
67
+ .eq('data->>slug', slug)
68
+ .single()
69
+
70
+ if (!tag) return null
71
+
72
+ return {
73
+ id: tag.id,
74
+ slug: tag.data?.slug || '',
75
+ name: tag.title,
76
+ purpose: tag.description,
77
+ category: tag.data?.category || '',
78
+ applicable_to: tag.data?.applicable_to || ['ticket'],
79
+ usage_count: tag.data?.usage_count || 0,
80
+ created_at: tag.created_at,
81
+ updated_at: tag.updated_at
82
+ }
83
+ }
84
+
85
+ async function createTag(
86
+ tagData: TagRequest,
87
+ accountId: string
88
+ ): Promise<TagResponse> {
89
+ const tagTypeId = await getTagTypeId()
90
+
91
+ const { data: tag, error } = await adminDb
92
+ .from('items')
93
+ .insert({
94
+ type_id: tagTypeId,
95
+ account_id: accountId,
96
+ title: tagData.name,
97
+ description: tagData.purpose,
98
+ data: {
99
+ slug: tagData.slug,
100
+ name: tagData.name,
101
+ purpose: tagData.purpose,
102
+ applicable_to: tagData.applicable_to || ['ticket'],
103
+ category: tagData.category,
104
+ usage_count: 1
105
+ },
106
+ status: 'active'
107
+ })
108
+ .select('*')
109
+ .single()
110
+
111
+ if (error || !tag) throw new Error(`Failed to create tag: ${error?.message}`)
112
+
113
+ return {
114
+ id: tag.id,
115
+ slug: tag.data?.slug || '',
116
+ name: tag.title,
117
+ purpose: tag.description,
118
+ category: tag.data?.category || '',
119
+ applicable_to: tag.data?.applicable_to || ['ticket'],
120
+ usage_count: tag.data?.usage_count || 0,
121
+ created_at: tag.created_at,
122
+ updated_at: tag.updated_at
123
+ }
124
+ }
125
+
126
+ async function incrementTagUsage(tagId: string): Promise<void> {
127
+ await adminDb
128
+ .from('items')
129
+ .update({
130
+ data: adminDb.sql`jsonb_set(data, '{usage_count}', COALESCE((data->>'usage_count')::int, 0) + 1)`,
131
+ updated_at: new Date().toISOString()
132
+ })
133
+ .eq('id', tagId)
134
+ }
135
+
136
+ // ─── ACTIONS ───────────────────────────────────────────────────────────────────
137
+
138
+ async function handleCreateOrGetTag(
139
+ ctx: any,
140
+ body: TagRequest
141
+ ): Promise<TagResponse> {
142
+ const account_id = ctx.accountId as string
143
+
144
+ if (!account_id) throw new Error('Account context required')
145
+ if (!body.slug || !body.name || !body.category) {
146
+ throw new Error('slug, name, and category are required')
147
+ }
148
+
149
+ // Validate slug format
150
+ if (!/^[a-z0-9_-]+$/.test(body.slug)) {
151
+ throw new Error('Slug must contain only lowercase letters, numbers, hyphens, and underscores')
152
+ }
153
+
154
+ // Try to find existing tag
155
+ const existingTag = await findExistingTag(body.slug)
156
+
157
+ if (existingTag) {
158
+ // Increment usage count
159
+ await incrementTagUsage(existingTag.id)
160
+
161
+ // Return updated tag with incremented count
162
+ const updatedTag = await findExistingTag(body.slug)
163
+ if (!updatedTag) throw new Error('Failed to retrieve updated tag')
164
+
165
+ return updatedTag
166
+ }
167
+
168
+ // Create new tag
169
+ return await createTag(body, account_id)
170
+ }
171
+
172
+ async function handleListTags(
173
+ ctx: any,
174
+ body: ListTagsRequest
175
+ ): Promise<{ tags: TagResponse[]; total: number }> {
176
+ const { category, applicable_to, limit = 50, offset = 0 } = body
177
+ const tagTypeId = await getTagTypeId()
178
+
179
+ let query = adminDb
180
+ .from('items')
181
+ .select('*', { count: 'exact' })
182
+ .eq('type_id', tagTypeId)
183
+ .eq('status', 'active')
184
+ .order('data->>usage_count', { ascending: false })
185
+ .range(offset, offset + limit - 1)
186
+
187
+ // Add filters
188
+ if (category) {
189
+ query = query.eq('data->>category', category)
190
+ }
191
+
192
+ if (applicable_to) {
193
+ query = query.contains('data->>applicable_to', [applicable_to])
194
+ }
195
+
196
+ const { data: tags, error, count } = await query
197
+
198
+ if (error) throw new Error(`Failed to list tags: ${error?.message}`)
199
+
200
+ const formattedTags: TagResponse[] = (tags || []).map(tag => ({
201
+ id: tag.id,
202
+ slug: tag.data?.slug || '',
203
+ name: tag.title,
204
+ purpose: tag.description,
205
+ category: tag.data?.category || '',
206
+ applicable_to: tag.data?.applicable_to || ['ticket'],
207
+ usage_count: tag.data?.usage_count || 0,
208
+ created_at: tag.created_at,
209
+ updated_at: tag.updated_at
210
+ }))
211
+
212
+ return {
213
+ tags: formattedTags,
214
+ total: count || 0
215
+ }
216
+ }
217
+
218
+ async function handleUpdateTagUsage(
219
+ ctx: any,
220
+ body: { tag_id: string }
221
+ ): Promise<void> {
222
+ if (!body.tag_id) throw new Error('tag_id is required')
223
+
224
+ await incrementTagUsage(body.tag_id)
225
+ }
226
+
227
+ async function handleMergeTags(
228
+ ctx: any,
229
+ body: { source_tag_id: string; target_tag_id: string }
230
+ ): Promise<{ merged_count: number }> {
231
+ const { source_tag_id, target_tag_id } = body
232
+
233
+ if (!source_tag_id || !target_tag_id) {
234
+ throw new Error('source_tag_id and target_tag_id are required')
235
+ }
236
+
237
+ if (source_tag_id === target_tag_id) {
238
+ throw new Error('Source and target tags cannot be the same')
239
+ }
240
+
241
+ // Get source tag info
242
+ const { data: sourceTag } = await adminDb
243
+ .from('items')
244
+ .select('data')
245
+ .eq('id', source_tag_id)
246
+ .single()
247
+
248
+ if (!sourceTag) throw new Error('Source tag not found')
249
+
250
+ // Update all references to source tag to point to target tag
251
+ const sourceTagSlug = sourceTag.data?.slug
252
+
253
+ // Update ticket analysis_tags arrays
254
+ const { data: ticketsToUpdate } = await adminDb
255
+ .from('items')
256
+ .select('id, data')
257
+ .eq('status', 'active')
258
+ .contains('data->>case_analysis->>analysis_tags', [source_tag_id])
259
+
260
+ let mergedCount = 0
261
+
262
+ for (const ticket of ticketsToUpdate || []) {
263
+ const caseAnalysis = ticket.data?.case_analysis || {}
264
+ const analysisTags = caseAnalysis.analysis_tags || []
265
+
266
+ // Replace source tag with target tag
267
+ const updatedTags = analysisTags.map((tagId: string) =>
268
+ tagId === source_tag_id ? target_tag_id : tagId
269
+ )
270
+
271
+ await adminDb
272
+ .from('items')
273
+ .update({
274
+ data: adminDb.sql`jsonb_set(data, '{case_analysis,analysis_tags}', ${updatedTags}::jsonb)`,
275
+ updated_at: new Date().toISOString()
276
+ })
277
+ .eq('id', ticket.id)
278
+
279
+ mergedCount++
280
+ }
281
+
282
+ // Increment target tag usage by source tag's usage count
283
+ const sourceUsage = sourceTag.data?.usage_count || 0
284
+ for (let i = 0; i < sourceUsage; i++) {
285
+ await incrementTagUsage(target_tag_id)
286
+ }
287
+
288
+ // Delete source tag
289
+ await adminDb
290
+ .from('items')
291
+ .update({ status: 'deleted', updated_at: new Date().toISOString() })
292
+ .eq('id', source_tag_id)
293
+
294
+ return { merged_count }
295
+ }
296
+
297
+ // ─── HANDLER ──────────────────────────────────────────────────────────────────
298
+
299
+ export const handler = createHandler(async (ctx, body) => {
300
+ const action = ctx.query?.action
301
+
302
+ switch (action) {
303
+ case 'create_or_get_tag':
304
+ return await handleCreateOrGetTag(ctx, body)
305
+ case 'list_tags':
306
+ return await handleListTags(ctx, body)
307
+ case 'update_tag_usage':
308
+ return await handleUpdateTagUsage(ctx, body)
309
+ case 'merge_tags':
310
+ return await handleMergeTags(ctx, body)
311
+ default:
312
+ throw new Error(`Unknown action: ${action}. Use create_or_get_tag, list_tags, update_tag_usage, or merge_tags.`)
313
+ }
314
+ })
package/manifest.json CHANGED
@@ -86,6 +86,7 @@
86
86
  ],
87
87
  "features": ["crm", "support", "community", "kb", "courses", "intelligence"],
88
88
  "dependencies": ["items", "accounts", "pipelines", "integrations"],
89
+ "prod_domain": "cortex.spine-framework.com",
89
90
  "entry_point": "./index.tsx",
90
91
  "sidebar_component": "./components/CortexSidebar.tsx",
91
92
  "seed": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework-cortex",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "private": false,
5
5
  "description": "Cortex — AI-powered support, CRM, and knowledge base app for Spine Framework",
6
6
  "keywords": [
@@ -1,7 +1,7 @@
1
1
  import { useState, useEffect, useRef } from 'react'
2
2
  import { useNavigate } from 'react-router-dom'
3
3
  import { apiFetch } from '@core/lib/api'
4
- import { getTypeIdAsync } from '../hooks/useTypeRegistry'
4
+ import { getTypeIdAsync } from '../../hooks/useTypeRegistry'
5
5
  import { Button } from '@core/components/ui/button'
6
6
  import { Input } from '@core/components/ui/input'
7
7
  import { Badge } from '@core/components/ui/badge'
@@ -1,7 +1,7 @@
1
1
  import { useState, useEffect } from 'react'
2
2
  import { useParams, useNavigate } from 'react-router-dom'
3
3
  import { apiFetch } from '@core/lib/api'
4
- import { getTypeIdAsync } from '../hooks/useTypeRegistry'
4
+ import { getTypeIdAsync } from '../../hooks/useTypeRegistry'
5
5
  import { RichTextEditor } from '@core/components/ui/RichTextEditor'
6
6
  import { Button } from '@core/components/ui/button'
7
7
  import { Input } from '@core/components/ui/input'
@@ -1,7 +1,7 @@
1
1
  import { useState, useEffect, useCallback } from 'react'
2
2
  import { useParams, useNavigate } from 'react-router-dom'
3
3
  import { apiFetch } from '@core/lib/api'
4
- import { getTypeIdAsync } from '../hooks/useTypeRegistry'
4
+ import { getTypeIdAsync } from '../../hooks/useTypeRegistry'
5
5
  import { Button } from '@core/components/ui/button'
6
6
  import { Badge } from '@core/components/ui/badge'
7
7
  import { ScrollArea } from '@core/components/ui/scroll-area'