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.
- 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 +29 -10
- package/pages/courses/CoursesPage.tsx +2 -14
- package/pages/kb/KBEditorPage.tsx +1 -10
- package/pages/support/RedactionReview.tsx +1 -12
- package/pages/support/TicketDetailPage.tsx +34 -46
- package/seed/types.json +2 -2
- package/hooks/useTypeRegistry.ts +0 -74
|
@@ -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.26",
|
|
6
6
|
"app_type": "full",
|
|
7
7
|
"route_prefix": "/cortex",
|
|
8
8
|
"required_roles": ["support", "support_admin"],
|
package/package.json
CHANGED
|
@@ -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()
|
|
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({
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
+
type_slug: 'kb_article',
|
|
280
269
|
title: articleTitle,
|
|
281
270
|
status: 'published',
|
|
282
271
|
description: finalContent,
|