spine-framework-cortex 0.2.25 → 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.
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.25",
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.25",
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,28 +20,20 @@ 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 [threadTypeId, setThreadTypeId] = useState<string | null>(null)
25
- const [messageTypeId, setMessageTypeId] = useState<string | null>(null)
26
23
 
27
24
  useEffect(() => {
28
- getTypeIdAsync('community_thread').then(id => setThreadTypeId(id))
29
- getTypeIdAsync('community_reply').then(id => setMessageTypeId(id))
30
- }, [])
31
-
32
- useEffect(() => {
33
- if (!threadTypeId) return
34
25
  setLoading(true)
35
- apiFetch(`/api/admin-data?action=list&entity=threads&target_id=${post.id}&type_id=${threadTypeId}&limit=5`)
26
+ apiFetch(`/api/admin-data?action=list&entity=threads&target_id=${post.id}&type_slug=community_thread&limit=5`)
36
27
  .then(r => r.json()).then(j => {
37
28
  const t = (j?.data ?? j)?.[0] ?? null
38
29
  setThread(t)
39
30
  if (t) return apiFetch(`/api/admin-data?action=list&entity=messages&thread_id=${t.id}&limit=100`).then(r => r.json())
40
31
  return []
41
32
  }).then(raw => { const msgs = raw?.data ?? raw; setMessages(Array.isArray(msgs) ? msgs : []) }).catch(() => {}).finally(() => setLoading(false))
42
- }, [post.id, threadTypeId])
33
+ }, [post.id])
43
34
 
44
35
  const handleSend = async () => {
45
- if (!replyText.trim() || !messageTypeId || !threadTypeId) return
36
+ if (!replyText.trim()) return
46
37
  setSending(true)
47
38
  try {
48
39
  let activeThread = thread
@@ -51,7 +42,7 @@ function ThreadPane({ post }: { post: Post }) {
51
42
  method: 'POST', headers: { 'Content-Type': 'application/json' },
52
43
  body: JSON.stringify({
53
44
  entity: 'threads',
54
- type_id: threadTypeId,
45
+ type_slug: 'community_thread',
55
46
  target_type: 'items',
56
47
  target_id: post.id,
57
48
  status: 'open',
@@ -67,7 +58,7 @@ function ThreadPane({ post }: { post: Post }) {
67
58
  method: 'POST', headers: { 'Content-Type': 'application/json' },
68
59
  body: JSON.stringify({
69
60
  thread_id: activeThread.id,
70
- type_id: messageTypeId,
61
+ type_slug: 'community_reply',
71
62
  content: replyText.trim(),
72
63
  direction: 'outbound',
73
64
  sequence: messages.reduce((max, m) => Math.max(max, m.sequence || 0), 0) + 1,
@@ -101,7 +92,7 @@ function ThreadPane({ post }: { post: Post }) {
101
92
  <div className="border-t p-3 flex gap-2 shrink-0">
102
93
  <Input placeholder="Reply as agent…" value={replyText} onChange={e => setReplyText(e.target.value)}
103
94
  onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()} className="flex-1" />
104
- <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>
105
96
  </div>
106
97
  </div>
107
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,
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from 'react'
1
+ import { useCallback, useEffect, useRef, useState } 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'
@@ -11,7 +11,6 @@ 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'
15
14
 
16
15
  interface Ticket {
17
16
  id: string;
@@ -83,14 +82,12 @@ function MergedThreadPanel({
83
82
  ticketId,
84
83
  externalThread,
85
84
  internalThread,
86
- threadTypeId,
87
- messageTypeId,
85
+ refreshThreads,
88
86
  }: {
89
87
  ticketId: string
90
88
  externalThread: Thread | null
91
89
  internalThread: Thread | null
92
- threadTypeId: string | null
93
- messageTypeId: string | null
90
+ refreshThreads: () => Promise<void>
94
91
  }) {
95
92
  const [messages, setMessages] = useState<Message[]>([])
96
93
  const [loading, setLoading] = useState(true)
@@ -137,32 +134,24 @@ function MergedThreadPanel({
137
134
  try {
138
135
  // Create internal thread if it doesn't exist
139
136
  if (!targetThread && replyType === 'internal') {
140
- if (!threadTypeId) {
141
- console.error('support_thread type not resolved')
142
- return
143
- }
144
-
145
137
  const threadRes = await apiFetch('/api/admin-data?action=create&entity=threads', {
146
138
  method: 'POST', headers: { 'Content-Type': 'application/json' },
147
139
  body: JSON.stringify({
148
140
  target_type: 'item',
149
141
  target_id: ticketId,
150
142
  visibility: 'internal',
151
- type_id: threadTypeId
143
+ type_slug: 'support_thread'
152
144
  }),
153
145
  })
154
146
  const newThread = await threadRes.json()
155
147
  if (newThread?.id) {
156
148
  targetThread = newThread
157
- // Refresh threads list
158
- const thr = await apiFetch(`/api/admin-data?action=list&entity=threads&target_id=${ticketId}&type_id=${threadTypeId}&limit=10`).then(r => r.json())
159
- const threadList = thr?.data ?? thr
160
- setThreads(Array.isArray(threadList) ? threadList : [])
149
+ await refreshThreads()
161
150
  }
162
151
  }
163
152
 
164
- if (!targetThread || !messageTypeId) {
165
- console.error('No thread or message type available for message')
153
+ if (!targetThread) {
154
+ console.error('No thread available for message')
166
155
  return
167
156
  }
168
157
 
@@ -177,7 +166,7 @@ function MergedThreadPanel({
177
166
  thread_id: targetThread.id,
178
167
  content: reply.trim(),
179
168
  direction: 'outbound',
180
- type_id: messageTypeId,
169
+ type_slug: 'support_reply',
181
170
  sequence: nextSeq,
182
171
  visibility: replyType
183
172
  }),
@@ -734,25 +723,25 @@ export default function TicketDetailPage() {
734
723
  const [watcherId, setWatcherId] = useState<string | null>(null)
735
724
  const [watchLoading, setWatchLoading] = useState(false)
736
725
  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
- }, [])
744
726
 
745
- useEffect(() => {
746
- if (!id || !threadTypeId) return
747
- Promise.all([
748
- apiFetch(`/api/admin-data?action=get&entity=items&id=${id}`).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()),
750
- ]).then(([ir, thr]) => {
727
+ const loadTicketAndThreads = useCallback(async () => {
728
+ if (!id) return
729
+ setLoading(true)
730
+ try {
731
+ const [ir, thr] = await Promise.all([
732
+ apiFetch(`/api/admin-data?action=get&entity=items&id=${id}`).then(r => r.json()),
733
+ apiFetch(`/api/admin-data?action=list&entity=threads&target_id=${id}&type_slug=support_thread&limit=10`).then(r => r.json()),
734
+ ])
751
735
  setTicket(ir?.data ?? ir ?? null)
752
736
  const threadList = thr?.data ?? thr
753
737
  setThreads(Array.isArray(threadList) ? threadList : [])
754
- }).catch(() => {}).finally(() => setLoading(false))
755
- }, [id, threadTypeId])
738
+ } catch {
739
+ } finally {
740
+ setLoading(false)
741
+ }
742
+ }, [id])
743
+
744
+ useEffect(() => { loadTicketAndThreads() }, [loadTicketAndThreads])
756
745
 
757
746
  // Check if current user is watching this ticket
758
747
  useEffect(() => {
@@ -778,16 +767,12 @@ export default function TicketDetailPage() {
778
767
  setIsWatching(false)
779
768
  setWatcherId(null)
780
769
  } else {
781
- // Resolve watcher type_id via types API
782
- const typeRes = await apiFetch('/api/types?kind=watcher&limit=1').then(r => r.json())
783
- const types = Array.isArray(typeRes?.data) ? typeRes.data : Array.isArray(typeRes) ? typeRes : []
784
- if (!types.length) { console.error('No watcher type found'); return }
785
770
  const res = await apiFetch('/api/admin-data?action=create&entity=watchers', {
786
771
  method: 'POST',
787
772
  headers: { 'Content-Type': 'application/json' },
788
773
  body: JSON.stringify({
789
774
  entity: 'watchers',
790
- type_id: types[0].id,
775
+ type_slug: 'watcher',
791
776
  target_type: 'item',
792
777
  target_id: id,
793
778
  person_id: user.id,
@@ -893,8 +878,7 @@ export default function TicketDetailPage() {
893
878
  ticketId={id || ''}
894
879
  externalThread={externalThread}
895
880
  internalThread={internalThread}
896
- threadTypeId={threadTypeId}
897
- messageTypeId={messageTypeId}
881
+ refreshThreads={loadTicketAndThreads}
898
882
  />
899
883
  </div>
900
884
 
@@ -1,74 +0,0 @@
1
- import { useState, useEffect } from 'react'
2
- import { supabase } from '@core/lib/supabase'
3
-
4
- interface TypeRecord {
5
- id: string
6
- slug: string
7
- name: string
8
- description?: string
9
- }
10
-
11
- // Module-level cache - persists across renders, shared across hooks
12
- let typeCache: Map<string, TypeRecord> | null = null
13
- let loadPromise: Promise<Map<string, TypeRecord>> | null = null
14
-
15
- async function fetchTypes(): Promise<Map<string, TypeRecord>> {
16
- if (typeCache) return typeCache
17
- if (loadPromise) return loadPromise
18
-
19
- loadPromise = (async () => {
20
- try {
21
- // Query Supabase directly - types is a config table, not exposed via admin-data
22
- const { data: types, error } = await supabase
23
- .from('types')
24
- .select('id, slug, name, description')
25
- .eq('is_active', true)
26
- .limit(100)
27
-
28
- if (error) throw error
29
-
30
- typeCache = new Map((types || []).map(t => [t.slug, t]))
31
- return typeCache
32
- } catch (e) {
33
- console.error('Failed to load types:', e)
34
- typeCache = new Map() // Empty cache on error
35
- return typeCache
36
- } finally {
37
- loadPromise = null
38
- }
39
- })()
40
-
41
- return loadPromise
42
- }
43
-
44
- export function useTypeRegistry() {
45
- const [types, setTypes] = useState<Map<string, TypeRecord>>(typeCache || new Map())
46
- const [loading, setLoading] = useState(!typeCache)
47
- const [error, setError] = useState<string | null>(null)
48
-
49
- useEffect(() => {
50
- if (typeCache) return
51
-
52
- fetchTypes()
53
- .then(cache => {
54
- setTypes(cache)
55
- setLoading(false)
56
- })
57
- .catch(e => {
58
- setError(e.message)
59
- setLoading(false)
60
- })
61
- }, [])
62
-
63
- const getTypeId = (slug: string): string | null => {
64
- return types.get(slug)?.id || null
65
- }
66
-
67
- return { types, loading, error, getTypeId }
68
- }
69
-
70
- // Synchronous version for use inside async functions
71
- export async function getTypeIdAsync(slug: string): Promise<string | null> {
72
- const cache = await fetchTypes()
73
- return cache.get(slug)?.id || null
74
- }