spine-framework-cortex 0.2.24 → 0.2.26

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.26",
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.26",
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,6 @@
1
1
  import { useState, useEffect } from 'react'
2
2
  import { Send, Hash, MessageSquarePlus, AlertCircle } from 'lucide-react'
3
3
  import { apiFetch } from '@core/lib/api'
4
- import { getTypeIdAsync } from '../../hooks/useTypeRegistry'
5
4
  import { Button } from '@core/components/ui/button'
6
5
  import { Input } from '@core/components/ui/input'
7
6
  import { Badge } from '@core/components/ui/badge'
@@ -21,15 +20,10 @@ function ThreadPane({ post }: { post: Post }) {
21
20
  const [loading, setLoading] = useState(true)
22
21
  const [replyText, setReplyText] = useState('')
23
22
  const [sending, setSending] = useState(false)
24
- const [messageTypeId, setMessageTypeId] = useState<string | null>(null)
25
-
26
- useEffect(() => {
27
- getTypeIdAsync('message').then(id => setMessageTypeId(id))
28
- }, [])
29
23
 
30
24
  useEffect(() => {
31
25
  setLoading(true)
32
- apiFetch(`/api/admin-data?action=list&entity=threads&target_id=${post.id}&limit=5`)
26
+ apiFetch(`/api/admin-data?action=list&entity=threads&target_id=${post.id}&type_slug=community_thread&limit=5`)
33
27
  .then(r => r.json()).then(j => {
34
28
  const t = (j?.data ?? j)?.[0] ?? null
35
29
  setThread(t)
@@ -39,12 +33,37 @@ function ThreadPane({ post }: { post: Post }) {
39
33
  }, [post.id])
40
34
 
41
35
  const handleSend = async () => {
42
- if (!replyText.trim() || !thread || !messageTypeId) return
36
+ if (!replyText.trim()) return
43
37
  setSending(true)
44
38
  try {
39
+ let activeThread = thread
40
+ if (!activeThread?.id) {
41
+ const res = await apiFetch('/api/admin-data?action=create&entity=threads', {
42
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify({
44
+ entity: 'threads',
45
+ type_slug: 'community_thread',
46
+ target_type: 'items',
47
+ target_id: post.id,
48
+ status: 'open',
49
+ }),
50
+ })
51
+ const raw = await res.json()
52
+ activeThread = raw?.data ?? raw
53
+ if (activeThread?.id) setThread(activeThread)
54
+ }
55
+ if (!activeThread?.id) return
56
+
45
57
  const res = await apiFetch('/api/admin-data?action=create&entity=messages', {
46
58
  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' }),
59
+ body: JSON.stringify({
60
+ thread_id: activeThread.id,
61
+ type_slug: 'community_reply',
62
+ content: replyText.trim(),
63
+ direction: 'outbound',
64
+ sequence: messages.reduce((max, m) => Math.max(max, m.sequence || 0), 0) + 1,
65
+ entity: 'messages',
66
+ }),
48
67
  })
49
68
  const raw = await res.json()
50
69
  const msg = raw?.data ?? raw
@@ -73,7 +92,7 @@ function ThreadPane({ post }: { post: Post }) {
73
92
  <div className="border-t p-3 flex gap-2 shrink-0">
74
93
  <Input placeholder="Reply as agent…" value={replyText} onChange={e => setReplyText(e.target.value)}
75
94
  onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()} className="flex-1" />
76
- <Button size="icon" onClick={handleSend} disabled={sending || !replyText.trim() || !thread || !messageTypeId}><Send size={14} /></Button>
95
+ <Button size="icon" onClick={handleSend} disabled={sending || !replyText.trim() || !thread}><Send size={14} /></Button>
77
96
  </div>
78
97
  </div>
79
98
  )
@@ -1,7 +1,6 @@
1
- import { useState, useEffect, useRef } from 'react'
1
+ import { useState, useEffect } from 'react'
2
2
  import { useNavigate } from 'react-router-dom'
3
3
  import { apiFetch } from '@core/lib/api'
4
- import { getTypeIdAsync } from '../../hooks/useTypeRegistry'
5
4
  import { Button } from '@core/components/ui/button'
6
5
  import { Input } from '@core/components/ui/input'
7
6
  import { Badge } from '@core/components/ui/badge'
@@ -31,13 +30,6 @@ function DiscussionPanel({ lesson }: { lesson: Lesson }) {
31
30
  const [loading, setLoading] = useState(true)
32
31
  const [newMessage, setNewMessage] = useState('')
33
32
  const [submitting, setSubmitting] = useState(false)
34
- const messageTypeIdRef = useRef<string>('')
35
-
36
- useEffect(() => {
37
- getTypeIdAsync('message').then(id => {
38
- if (id) messageTypeIdRef.current = id
39
- })
40
- }, [])
41
33
 
42
34
  useEffect(() => {
43
35
  setLoading(true)
@@ -53,10 +45,6 @@ function DiscussionPanel({ lesson }: { lesson: Lesson }) {
53
45
  const handleSubmit = async (e: React.FormEvent) => {
54
46
  e.preventDefault()
55
47
  if (!newMessage.trim() || submitting) return
56
- if (!messageTypeIdRef.current) {
57
- console.error('Message type not loaded')
58
- return
59
- }
60
48
 
61
49
  setSubmitting(true)
62
50
  try {
@@ -83,7 +71,7 @@ function DiscussionPanel({ lesson }: { lesson: Lesson }) {
83
71
  method: 'POST',
84
72
  headers: { 'Content-Type': 'application/json' },
85
73
  body: JSON.stringify({
86
- type_id: messageTypeIdRef.current,
74
+ type_slug: 'message',
87
75
  thread_id: currentThread.id,
88
76
  content: newMessage.trim(),
89
77
  direction: 'inbound',
@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'
2
2
  import { useParams, useNavigate } from 'react-router-dom'
3
3
  import { useAppPath } from '@core/hooks/useAppPath'
4
4
  import { apiFetch } from '@core/lib/api'
5
- import { getTypeIdAsync } from '../../hooks/useTypeRegistry'
6
5
  import { RichTextEditor } from '@core/components/ui/RichTextEditor'
7
6
  import { Button } from '@core/components/ui/button'
8
7
  import { Input } from '@core/components/ui/input'
@@ -73,7 +72,6 @@ export default function KBEditorPage() {
73
72
  const [loading, setLoading] = useState(!isNew)
74
73
  const [saving, setSaving] = useState(false)
75
74
  const [error, setError] = useState<string | null>(null)
76
- const [kbArticleTypeId, setKbArticleTypeId] = useState<string>('')
77
75
 
78
76
  useEffect(() => {
79
77
  if (isNew) return
@@ -97,23 +95,16 @@ export default function KBEditorPage() {
97
95
  .finally(() => setLoading(false))
98
96
  }, [id, isNew])
99
97
 
100
- useEffect(() => {
101
- getTypeIdAsync('kb_article').then(id => {
102
- if (id) setKbArticleTypeId(id)
103
- })
104
- }, [])
105
-
106
98
  const handleSave = async (publish?: boolean) => {
107
99
  if (!form.title.trim()) { setError('Title is required'); return }
108
100
  if (!form.kb_type) { setError('KB Type is required'); return }
109
- if (!kbArticleTypeId) { setError('Type not loaded'); return }
110
101
  setSaving(true)
111
102
  setError(null)
112
103
  try {
113
104
  const payload = {
114
105
  title: form.title.trim(),
115
106
  description: form.description,
116
- type_id: kbArticleTypeId,
107
+ type_slug: 'kb_article',
117
108
  status: publish ? 'published' : form.status,
118
109
  data: {
119
110
  kb_type: form.kb_type,
@@ -2,7 +2,6 @@ import { useState, useEffect, useCallback } from 'react'
2
2
  import { useParams, useNavigate } from 'react-router-dom'
3
3
  import { useAppPath } from '@core/hooks/useAppPath'
4
4
  import { apiFetch } from '@core/lib/api'
5
- import { getTypeIdAsync } from '../../hooks/useTypeRegistry'
6
5
  import { Button } from '@core/components/ui/button'
7
6
  import { Badge } from '@core/components/ui/badge'
8
7
  import { ScrollArea } from '@core/components/ui/scroll-area'
@@ -107,7 +106,6 @@ export default function RedactionReview() {
107
106
  const [finalContent, setFinalContent] = useState('')
108
107
  const [articleTitle, setArticleTitle] = useState('')
109
108
  const [viewMode, setViewMode] = useState<'review' | 'final'>('review')
110
- const [kbArticleTypeId, setKbArticleTypeId] = useState<string>('')
111
109
 
112
110
  // Load ticket and trigger redaction analysis
113
111
  useEffect(() => {
@@ -155,11 +153,6 @@ export default function RedactionReview() {
155
153
  }
156
154
 
157
155
  loadAndAnalyze()
158
-
159
- // Load KB article type ID
160
- getTypeIdAsync('kb_article').then(id => {
161
- if (id) setKbArticleTypeId(id)
162
- })
163
156
  }, [id])
164
157
 
165
158
  // Build content from ticket for redaction analysis.
@@ -264,10 +257,6 @@ export default function RedactionReview() {
264
257
  // Publish KB article
265
258
  const publishKB = async () => {
266
259
  if (!id || !finalContent || !articleTitle) return
267
- if (!kbArticleTypeId) {
268
- console.error('KB article type not loaded')
269
- return
270
- }
271
260
 
272
261
  setProcessing(true)
273
262
  try {
@@ -276,7 +265,7 @@ export default function RedactionReview() {
276
265
  method: 'POST',
277
266
  headers: { 'Content-Type': 'application/json' },
278
267
  body: JSON.stringify({
279
- type_id: kbArticleTypeId,
268
+ type_slug: 'kb_article',
280
269
  title: articleTitle,
281
270
  status: 'published',
282
271
  description: finalContent,