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.
- package/LICENSE.md +193 -0
- package/README.md +46 -0
- package/functions/custom_case_analysis.ts +1 -1
- package/functions/custom_cortex-handler.ts +35 -0
- package/functions/custom_kb-chunker-test.ts +364 -0
- package/functions/custom_kb-ingestion.ts +447 -0
- package/functions/custom_support-redaction.ts +1 -1
- package/functions/custom_support-solution.ts +1 -1
- package/functions/custom_tag_management.ts +314 -0
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/pages/community/CommunityPage.tsx +33 -5
- package/pages/support/TicketDetailPage.tsx +37 -33
- package/seed/types.json +2 -2
|
@@ -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.
|
|
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
|
@@ -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('
|
|
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() || !
|
|
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({
|
|
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
|
-
|
|
136
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
+
]
|