spine-framework-cortex 0.2.24 → 0.2.25

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
@@ -2,7 +2,7 @@
2
2
  "name": "Cortex",
3
3
  "slug": "cortex",
4
4
  "description": "Unified workspace for CRM, Support, Community, and Knowledge Base",
5
- "version": "0.2.22",
5
+ "version": "0.2.25",
6
6
  "app_type": "full",
7
7
  "route_prefix": "/cortex",
8
8
  "required_roles": ["support", "support_admin"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework-cortex",
3
- "version": "0.2.24",
3
+ "version": "0.2.25",
4
4
  "private": false,
5
5
  "description": "Cortex — AI-powered support, CRM, and knowledge base app for Spine Framework",
6
6
  "keywords": [
@@ -21,30 +21,58 @@ function ThreadPane({ post }: { post: Post }) {
21
21
  const [loading, setLoading] = useState(true)
22
22
  const [replyText, setReplyText] = useState('')
23
23
  const [sending, setSending] = useState(false)
24
+ const [threadTypeId, setThreadTypeId] = useState<string | null>(null)
24
25
  const [messageTypeId, setMessageTypeId] = useState<string | null>(null)
25
26
 
26
27
  useEffect(() => {
27
- getTypeIdAsync('message').then(id => setMessageTypeId(id))
28
+ getTypeIdAsync('community_thread').then(id => setThreadTypeId(id))
29
+ getTypeIdAsync('community_reply').then(id => setMessageTypeId(id))
28
30
  }, [])
29
31
 
30
32
  useEffect(() => {
33
+ if (!threadTypeId) return
31
34
  setLoading(true)
32
- apiFetch(`/api/admin-data?action=list&entity=threads&target_id=${post.id}&limit=5`)
35
+ apiFetch(`/api/admin-data?action=list&entity=threads&target_id=${post.id}&type_id=${threadTypeId}&limit=5`)
33
36
  .then(r => r.json()).then(j => {
34
37
  const t = (j?.data ?? j)?.[0] ?? null
35
38
  setThread(t)
36
39
  if (t) return apiFetch(`/api/admin-data?action=list&entity=messages&thread_id=${t.id}&limit=100`).then(r => r.json())
37
40
  return []
38
41
  }).then(raw => { const msgs = raw?.data ?? raw; setMessages(Array.isArray(msgs) ? msgs : []) }).catch(() => {}).finally(() => setLoading(false))
39
- }, [post.id])
42
+ }, [post.id, threadTypeId])
40
43
 
41
44
  const handleSend = async () => {
42
- if (!replyText.trim() || !thread || !messageTypeId) return
45
+ if (!replyText.trim() || !messageTypeId || !threadTypeId) return
43
46
  setSending(true)
44
47
  try {
48
+ let activeThread = thread
49
+ if (!activeThread?.id) {
50
+ const res = await apiFetch('/api/admin-data?action=create&entity=threads', {
51
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
52
+ body: JSON.stringify({
53
+ entity: 'threads',
54
+ type_id: threadTypeId,
55
+ target_type: 'items',
56
+ target_id: post.id,
57
+ status: 'open',
58
+ }),
59
+ })
60
+ const raw = await res.json()
61
+ activeThread = raw?.data ?? raw
62
+ if (activeThread?.id) setThread(activeThread)
63
+ }
64
+ if (!activeThread?.id) return
65
+
45
66
  const res = await apiFetch('/api/admin-data?action=create&entity=messages', {
46
67
  method: 'POST', headers: { 'Content-Type': 'application/json' },
47
- body: JSON.stringify({ thread_id: thread.id, type_id: messageTypeId, content: replyText.trim(), direction: 'outbound', entity: 'messages' }),
68
+ body: JSON.stringify({
69
+ thread_id: activeThread.id,
70
+ type_id: messageTypeId,
71
+ content: replyText.trim(),
72
+ direction: 'outbound',
73
+ sequence: messages.reduce((max, m) => Math.max(max, m.sequence || 0), 0) + 1,
74
+ entity: 'messages',
75
+ }),
48
76
  })
49
77
  const raw = await res.json()
50
78
  const msg = raw?.data ?? raw
@@ -11,6 +11,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@core/components/ui/ta
11
11
  import { Textarea } from '@core/components/ui/textarea'
12
12
  import { ArrowLeft, Send, Lock, Globe, Bot, CheckCircle, AlertCircle, FileText, Building2, CreditCard, Package, BookOpen, Eye, EyeOff, Brain, Clock, TrendingUp, Tag as TagIcon, Sparkles } from 'lucide-react'
13
13
  import { useAuth } from '@core/contexts/AuthContext'
14
+ import { getTypeIdAsync } from '../../hooks/useTypeRegistry'
14
15
 
15
16
  interface Ticket {
16
17
  id: string;
@@ -78,14 +79,18 @@ const STATUS_BADGE: Record<string, string> = {
78
79
  }
79
80
 
80
81
  // Merged Thread Panel - combines internal and external messages
81
- function MergedThreadPanel({
82
- ticketId,
83
- externalThread,
84
- internalThread
85
- }: {
82
+ function MergedThreadPanel({
83
+ ticketId,
84
+ externalThread,
85
+ internalThread,
86
+ threadTypeId,
87
+ messageTypeId,
88
+ }: {
86
89
  ticketId: string
87
90
  externalThread: Thread | null
88
91
  internalThread: Thread | null
92
+ threadTypeId: string | null
93
+ messageTypeId: string | null
89
94
  }) {
90
95
  const [messages, setMessages] = useState<Message[]>([])
91
96
  const [loading, setLoading] = useState(true)
@@ -132,10 +137,8 @@ function MergedThreadPanel({
132
137
  try {
133
138
  // Create internal thread if it doesn't exist
134
139
  if (!targetThread && replyType === 'internal') {
135
- const threadTypesRes = await apiFetch('/api/types?kind=thread&limit=1').then(r => r.json())
136
- const threadTypes = Array.isArray(threadTypesRes?.data) ? threadTypesRes.data : Array.isArray(threadTypesRes) ? threadTypesRes : []
137
- if (!threadTypes.length) {
138
- console.error('No thread type found')
140
+ if (!threadTypeId) {
141
+ console.error('support_thread type not resolved')
139
142
  return
140
143
  }
141
144
 
@@ -145,29 +148,21 @@ function MergedThreadPanel({
145
148
  target_type: 'item',
146
149
  target_id: ticketId,
147
150
  visibility: 'internal',
148
- type_id: threadTypes[0].id
151
+ type_id: threadTypeId
149
152
  }),
150
153
  })
151
154
  const newThread = await threadRes.json()
152
155
  if (newThread?.id) {
153
156
  targetThread = newThread
154
157
  // Refresh threads list
155
- const thr = await apiFetch(`/api/admin-data?action=list&entity=threads&target_id=${ticketId}&limit=10`).then(r => r.json())
158
+ const thr = await apiFetch(`/api/admin-data?action=list&entity=threads&target_id=${ticketId}&type_id=${threadTypeId}&limit=10`).then(r => r.json())
156
159
  const threadList = thr?.data ?? thr
157
160
  setThreads(Array.isArray(threadList) ? threadList : [])
158
161
  }
159
162
  }
160
163
 
161
- if (!targetThread) {
162
- console.error('No thread available for message')
163
- return
164
- }
165
-
166
- // Resolve message type_id
167
- const typesRes = await apiFetch('/api/types?kind=message&limit=1').then(r => r.json())
168
- const types = Array.isArray(typesRes?.data) ? typesRes.data : Array.isArray(typesRes) ? typesRes : []
169
- if (!types.length) {
170
- console.error('No message type found')
164
+ if (!targetThread || !messageTypeId) {
165
+ console.error('No thread or message type available for message')
171
166
  return
172
167
  }
173
168
 
@@ -178,11 +173,11 @@ function MergedThreadPanel({
178
173
 
179
174
  const res = await apiFetch('/api/admin-data?action=create&entity=messages', {
180
175
  method: 'POST', headers: { 'Content-Type': 'application/json' },
181
- body: JSON.stringify({
182
- thread_id: targetThread.id,
183
- content: reply.trim(),
184
- direction: 'outbound',
185
- type_id: types[0].id,
176
+ body: JSON.stringify({
177
+ thread_id: targetThread.id,
178
+ content: reply.trim(),
179
+ direction: 'outbound',
180
+ type_id: messageTypeId,
186
181
  sequence: nextSeq,
187
182
  visibility: replyType
188
183
  }),
@@ -739,18 +734,25 @@ export default function TicketDetailPage() {
739
734
  const [watcherId, setWatcherId] = useState<string | null>(null)
740
735
  const [watchLoading, setWatchLoading] = useState(false)
741
736
  const [analysisLoading, setAnalysisLoading] = useState(false)
737
+ const [threadTypeId, setThreadTypeId] = useState<string | null>(null)
738
+ const [messageTypeId, setMessageTypeId] = useState<string | null>(null)
739
+
740
+ useEffect(() => {
741
+ getTypeIdAsync('support_thread').then(id => setThreadTypeId(id))
742
+ getTypeIdAsync('support_reply').then(id => setMessageTypeId(id))
743
+ }, [])
742
744
 
743
745
  useEffect(() => {
744
- if (!id) return
746
+ if (!id || !threadTypeId) return
745
747
  Promise.all([
746
748
  apiFetch(`/api/admin-data?action=get&entity=items&id=${id}`).then(r => r.json()),
747
- apiFetch(`/api/admin-data?action=list&entity=threads&target_id=${id}&limit=10`).then(r => r.json()),
749
+ apiFetch(`/api/admin-data?action=list&entity=threads&target_id=${id}&type_id=${threadTypeId}&limit=10`).then(r => r.json()),
748
750
  ]).then(([ir, thr]) => {
749
751
  setTicket(ir?.data ?? ir ?? null)
750
752
  const threadList = thr?.data ?? thr
751
753
  setThreads(Array.isArray(threadList) ? threadList : [])
752
754
  }).catch(() => {}).finally(() => setLoading(false))
753
- }, [id])
755
+ }, [id, threadTypeId])
754
756
 
755
757
  // Check if current user is watching this ticket
756
758
  useEffect(() => {
@@ -887,10 +889,12 @@ export default function TicketDetailPage() {
887
889
  <div className="flex flex-1 min-h-0">
888
890
  {/* Main Conversation Panel */}
889
891
  <div className="flex-1 min-w-0 border-r border-border">
890
- <MergedThreadPanel
891
- ticketId={id || ''}
892
- externalThread={externalThread}
893
- internalThread={internalThread}
892
+ <MergedThreadPanel
893
+ ticketId={id || ''}
894
+ externalThread={externalThread}
895
+ internalThread={internalThread}
896
+ threadTypeId={threadTypeId}
897
+ messageTypeId={messageTypeId}
894
898
  />
895
899
  </div>
896
900
 
package/seed/types.json CHANGED
@@ -1868,7 +1868,7 @@
1868
1868
  "kind": "item",
1869
1869
  "slug": "course_lesson",
1870
1870
  "name": "Course Lesson",
1871
- "description": "Learning content unit part of a course sequence",
1871
+ "description": "Learning content unit \u2014 part of a course sequence",
1872
1872
  "icon": "graduation-cap",
1873
1873
  "color": "#F97316",
1874
1874
  "ownership": "tenant",
@@ -1986,4 +1986,4 @@
1986
1986
  },
1987
1987
  "validation_schema": {}
1988
1988
  }
1989
- ]
1989
+ ]