spine-framework-portal 0.2.19 → 0.2.21
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/functions/custom_portal-community-escalation.ts +1 -1
- package/functions/custom_support-triage.ts +83 -439
- package/hooks/usePortalThreads.ts +28 -8
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/pages/CommunityPage.tsx +1 -1
- package/pages/TicketsPage.tsx +1 -1
- package/seed/types.json +172 -75
|
@@ -165,7 +165,7 @@ export const checkUnanswered = createHandler(async (ctx, _body) => {
|
|
|
165
165
|
// Resolve type IDs dynamically
|
|
166
166
|
const [supportTicketTypeId, threadTypeId] = await Promise.all([
|
|
167
167
|
resolveTypeId('item', 'support_ticket'),
|
|
168
|
-
resolveTypeId('thread', '
|
|
168
|
+
resolveTypeId('thread', 'support_thread')
|
|
169
169
|
])
|
|
170
170
|
|
|
171
171
|
try {
|
|
@@ -1,489 +1,137 @@
|
|
|
1
1
|
import { createHandler, CoreContext } from './_shared/middleware'
|
|
2
2
|
import { adminDb } from './_shared/db'
|
|
3
3
|
import { resolveTypeId, resolveAgentId, resolvePromptConfigId } from './_shared/resolve-ids'
|
|
4
|
+
import { runAgent } from './_shared/index'
|
|
4
5
|
import { create, update } from './admin-data'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
+
* @module custom_support-triage
|
|
9
|
+
* @layer custom/portal
|
|
10
|
+
* @stability custom
|
|
8
11
|
*
|
|
9
|
-
* Two actions:
|
|
10
|
-
* POST ?action=new_ticket —
|
|
11
|
-
* POST ?action=reply —
|
|
12
|
+
* Support ticket entry point. Two actions:
|
|
13
|
+
* POST ?action=new_ticket — creates ticket + external thread, calls runAgent()
|
|
14
|
+
* POST ?action=reply — posts the human message, calls runAgent() on the existing thread
|
|
12
15
|
*
|
|
13
|
-
* All
|
|
14
|
-
*
|
|
16
|
+
* All AI inference (RAG, tool calls, confidence scoring, escalation pipeline) is
|
|
17
|
+
* delegated to core's runAgent() — agent behaviour is controlled entirely via
|
|
18
|
+
* ai_agents + prompt_configs records (no AI logic in this file).
|
|
15
19
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* escalate: boolean,
|
|
22
|
-
* escalation_reason: 'low_confidence' | 'none',
|
|
23
|
-
* sources_used: Array<{ type, id, title, relevance }>,
|
|
24
|
-
* suggested_title: string (turn 1 only)
|
|
25
|
-
* }
|
|
20
|
+
* Response shape:
|
|
21
|
+
* { ticketId, threadId, agentMessageId, content, confidence, escalated }
|
|
22
|
+
*
|
|
23
|
+
* Escalation is handled inside agent-runner.ts via the escalation_pipeline
|
|
24
|
+
* configured in the prompt_config row — no escalation code here.
|
|
26
25
|
*/
|
|
27
26
|
|
|
28
|
-
// ───
|
|
29
|
-
|
|
30
|
-
const CONFIDENCE_THRESHOLD = 0.75
|
|
27
|
+
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
|
31
28
|
|
|
32
|
-
// Helper: call admin-data.update as a nested import (entity+id go in ctx.query)
|
|
33
29
|
function adminDataUpdate(ctx: CoreContext, entity: string, id: string, fields: Record<string, any>) {
|
|
34
30
|
return update({ ...ctx, query: { ...((ctx as any).query || {}), entity, id } } as any, fields)
|
|
35
31
|
}
|
|
36
32
|
|
|
37
|
-
// ─── TYPES ────────────────────────────────────────────────────────────────────
|
|
38
|
-
|
|
39
|
-
interface TriageEnvelope {
|
|
40
|
-
public_response: string
|
|
41
|
-
confidence: number
|
|
42
|
-
confidence_reasoning: string
|
|
43
|
-
escalate: boolean
|
|
44
|
-
escalation_reason: 'low_confidence' | 'none'
|
|
45
|
-
sources_used: Array<{ type: string; id: string; title: string; relevance: number }>
|
|
46
|
-
suggested_title?: string
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface TriageResult {
|
|
50
|
-
ticketId: string
|
|
51
|
-
threadId: string
|
|
52
|
-
publicMessageId: string
|
|
53
|
-
internalMessageId: string
|
|
54
|
-
public_response: string
|
|
55
|
-
confidence: number
|
|
56
|
-
escalated: boolean
|
|
57
|
-
escalation_reason: string
|
|
58
|
-
suggested_title?: string
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
interface TypeIds {
|
|
62
|
-
supportTicket: string
|
|
63
|
-
thread: string
|
|
64
|
-
message: string
|
|
65
|
-
triageAgentId: string
|
|
66
|
-
promptConfigId: string
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
|
70
|
-
|
|
71
|
-
async function getConversationHistory(threadId: string): Promise<any[]> {
|
|
72
|
-
const { data: msgs, error } = await adminDb
|
|
73
|
-
.from('messages')
|
|
74
|
-
.select('content, direction, data, created_at')
|
|
75
|
-
.eq('thread_id', threadId)
|
|
76
|
-
.eq('visibility', 'public')
|
|
77
|
-
.order('created_at', { ascending: true })
|
|
78
|
-
.limit(20)
|
|
79
|
-
if (error) return []
|
|
80
|
-
return msgs || []
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function getPriorTickets(accountId: string, personId: string, supportTicketTypeId: string): Promise<any[]> {
|
|
84
|
-
const { data: tickets, error } = await adminDb
|
|
85
|
-
.from('items')
|
|
86
|
-
.select('id, title, description, created_at')
|
|
87
|
-
.eq('type_id', supportTicketTypeId)
|
|
88
|
-
.eq('account_id', accountId)
|
|
89
|
-
.eq('created_by', personId)
|
|
90
|
-
.order('created_at', { ascending: false })
|
|
91
|
-
.limit(5)
|
|
92
|
-
if (error) return []
|
|
93
|
-
return tickets || []
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const KB_PLATFORM_ACCOUNT_ID = '12acec9b-8451-40e7-80d5-e80c4e2fc0de' // spine-system
|
|
97
|
-
|
|
98
|
-
async function searchKB(query: string, accountId: string): Promise<any[]> {
|
|
99
|
-
const accountIds = [accountId]
|
|
100
|
-
if (accountId !== KB_PLATFORM_ACCOUNT_ID) accountIds.push(KB_PLATFORM_ACCOUNT_ID)
|
|
101
|
-
|
|
102
|
-
const { data: results, error } = await adminDb
|
|
103
|
-
.from('embeddings')
|
|
104
|
-
.select('document_id, content, metadata, account_id')
|
|
105
|
-
.in('account_id', accountIds)
|
|
106
|
-
.eq('metadata->>item_type', 'kb_article')
|
|
107
|
-
.eq('metadata->>vector_type', 'semantic')
|
|
108
|
-
.textSearch('content', query, { type: 'websearch', config: 'english' })
|
|
109
|
-
.limit(8)
|
|
110
|
-
|
|
111
|
-
if (error) return []
|
|
112
|
-
|
|
113
|
-
return (results || [])
|
|
114
|
-
.filter((r: any) => r.account_id === accountId || r.metadata?.security_level !== 'restricted')
|
|
115
|
-
.slice(0, 5)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
async function callOpenAI(
|
|
119
|
-
systemPrompt: string,
|
|
120
|
-
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
|
|
121
|
-
model: string,
|
|
122
|
-
temperature: number
|
|
123
|
-
): Promise<{ envelope: TriageEnvelope; latency_ms: number; token_usage: any }> {
|
|
124
|
-
const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
|
|
125
|
-
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
|
|
126
|
-
const startTime = Date.now()
|
|
127
|
-
|
|
128
|
-
if (!apiKey) {
|
|
129
|
-
return {
|
|
130
|
-
envelope: {
|
|
131
|
-
public_response: '[Mock] No OPENAI_API_KEY set. This is a mock AI response for local development.',
|
|
132
|
-
confidence: 0.85,
|
|
133
|
-
confidence_reasoning: 'Mock response — no API key configured.',
|
|
134
|
-
escalate: false,
|
|
135
|
-
escalation_reason: 'none',
|
|
136
|
-
sources_used: [],
|
|
137
|
-
suggested_title: 'Mock Support Ticket'
|
|
138
|
-
},
|
|
139
|
-
latency_ms: 0,
|
|
140
|
-
token_usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
145
|
-
method: 'POST',
|
|
146
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
147
|
-
body: JSON.stringify({
|
|
148
|
-
model,
|
|
149
|
-
temperature,
|
|
150
|
-
response_format: { type: 'json_object' },
|
|
151
|
-
messages: [{ role: 'system', content: systemPrompt }, ...messages]
|
|
152
|
-
})
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
if (!res.ok) {
|
|
156
|
-
const err = await res.text()
|
|
157
|
-
throw new Error(`OpenAI error ${res.status}: ${err.slice(0, 200)}`)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const result: any = await res.json()
|
|
161
|
-
const raw = result.choices?.[0]?.message?.content || '{}'
|
|
162
|
-
let envelope: TriageEnvelope
|
|
163
|
-
|
|
164
|
-
try {
|
|
165
|
-
envelope = JSON.parse(raw)
|
|
166
|
-
if (typeof envelope.public_response !== 'string') throw new Error('missing public_response')
|
|
167
|
-
if (typeof envelope.confidence !== 'number') throw new Error('missing confidence')
|
|
168
|
-
} catch (parseErr: any) {
|
|
169
|
-
envelope = {
|
|
170
|
-
public_response: "We're looking into this and will have a human response shortly.",
|
|
171
|
-
confidence: 0,
|
|
172
|
-
confidence_reasoning: `Parse failure: ${parseErr.message}. Raw: ${raw.slice(0, 200)}`,
|
|
173
|
-
escalate: true,
|
|
174
|
-
escalation_reason: 'low_confidence',
|
|
175
|
-
sources_used: []
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return { envelope, latency_ms: Date.now() - startTime, token_usage: result.usage || {} }
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function buildSystemPrompt(
|
|
183
|
-
agentSystemPrompt: string,
|
|
184
|
-
contextTemplate: string,
|
|
185
|
-
kbSources: any[],
|
|
186
|
-
priorTickets: any[],
|
|
187
|
-
history: any[]
|
|
188
|
-
): string {
|
|
189
|
-
let prompt = agentSystemPrompt + '\n\n'
|
|
190
|
-
prompt += contextTemplate + '\n\n'
|
|
191
|
-
|
|
192
|
-
if (kbSources.length > 0) {
|
|
193
|
-
prompt += '## Knowledge Base Articles\n'
|
|
194
|
-
kbSources.forEach((doc, i) => { prompt += `[KB${i + 1}] ${doc.content?.slice(0, 500)}\n` })
|
|
195
|
-
prompt += '\n'
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (priorTickets.length > 0) {
|
|
199
|
-
prompt += "## Customer's Prior Tickets\n"
|
|
200
|
-
priorTickets.forEach((t) => { prompt += `- ${t.title}: ${t.description?.slice(0, 200) || 'No description'}\n` })
|
|
201
|
-
prompt += '\n'
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (history.length > 0) {
|
|
205
|
-
prompt += '## Conversation So Far\n'
|
|
206
|
-
history.forEach((m) => {
|
|
207
|
-
prompt += `${m.direction === 'inbound' ? 'Customer' : 'Assistant'}: ${m.content}\n`
|
|
208
|
-
})
|
|
209
|
-
prompt += '\n'
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
prompt += `\nYou MUST respond with a single valid JSON object matching EXACTLY this schema. No markdown, no code fences, no extra text:\n`
|
|
213
|
-
prompt += `{
|
|
214
|
-
"public_response": "<your response to the customer>",
|
|
215
|
-
"confidence": <0.0-1.0>,
|
|
216
|
-
"confidence_reasoning": "<one sentence explaining your confidence score>",
|
|
217
|
-
"escalate": <true|false>,
|
|
218
|
-
"escalation_reason": "<low_confidence|none>",
|
|
219
|
-
"sources_used": [{"type": "kb_article|prior_ticket", "id": "<id>", "title": "<title>", "relevance": <0.0-1.0>}],
|
|
220
|
-
"suggested_title": "<3-8 word case title — ONLY include on the first turn>"
|
|
221
|
-
}`
|
|
222
|
-
|
|
223
|
-
return prompt
|
|
224
|
-
}
|
|
225
|
-
|
|
226
33
|
// ─── ACTION: NEW TICKET ───────────────────────────────────────────────────────
|
|
227
34
|
|
|
228
|
-
async function handleNewTicket(
|
|
35
|
+
async function handleNewTicket(
|
|
36
|
+
ctx: any,
|
|
37
|
+
body: any,
|
|
38
|
+
ids: { supportTicket: string; thread: string; triageAgentId: string; promptConfigId: string }
|
|
39
|
+
) {
|
|
229
40
|
const { message } = body
|
|
230
41
|
const account_id = ctx.accountId as string
|
|
231
42
|
const person_id = ctx.principal?.id as string
|
|
232
43
|
if (!message) throw new Error('message is required')
|
|
233
44
|
if (!account_id || !person_id) throw new Error('User context (account + person) required')
|
|
234
45
|
|
|
235
|
-
const { triageAgentId, promptConfigId } =
|
|
236
|
-
|
|
237
|
-
// Load agent + prompt config (config tables — direct reads are fine)
|
|
238
|
-
const [{ data: agent }, { data: promptConfig }] = await Promise.all([
|
|
239
|
-
adminDb.from('ai_agents').select('*').eq('id', triageAgentId).single(),
|
|
240
|
-
adminDb.from('prompt_configs').select('*').eq('id', promptConfigId).single()
|
|
241
|
-
])
|
|
242
|
-
if (!agent || !promptConfig) throw new Error('Triage agent configuration not found')
|
|
243
|
-
|
|
244
|
-
const model = agent.model_config?.model || process.env.LLM_DEFAULT_MODEL || 'gpt-4o'
|
|
245
|
-
const temperature = agent.model_config?.temperature ?? 0.7
|
|
246
|
-
|
|
247
|
-
// Run AI before ticket exists (no history yet)
|
|
248
|
-
const [kbSources, priorTickets] = await Promise.all([
|
|
249
|
-
searchKB(message, account_id),
|
|
250
|
-
getPriorTickets(account_id, person_id, typeIds.supportTicket)
|
|
251
|
-
])
|
|
252
|
-
const systemPrompt = buildSystemPrompt(
|
|
253
|
-
agent.system_prompt,
|
|
254
|
-
promptConfig.context_template.replace('{{user_message}}', message),
|
|
255
|
-
kbSources,
|
|
256
|
-
priorTickets,
|
|
257
|
-
[]
|
|
258
|
-
)
|
|
259
|
-
const { envelope, latency_ms, token_usage } = await callOpenAI(
|
|
260
|
-
systemPrompt,
|
|
261
|
-
[{ role: 'user', content: message }],
|
|
262
|
-
model,
|
|
263
|
-
temperature
|
|
264
|
-
)
|
|
46
|
+
const { supportTicket, thread, triageAgentId, promptConfigId } = ids
|
|
265
47
|
|
|
266
|
-
|
|
267
|
-
const suggestedTitle = envelope.suggested_title || message.slice(0, 80)
|
|
268
|
-
|
|
269
|
-
// Create ticket via admin-data (stamps schema, fires triggers)
|
|
48
|
+
// 1. Create a placeholder ticket — title will be updated after agent responds
|
|
270
49
|
const ticket = await create(ctx, {
|
|
271
50
|
entity: 'items',
|
|
272
|
-
type_id:
|
|
273
|
-
title:
|
|
51
|
+
type_id: supportTicket,
|
|
52
|
+
title: message.slice(0, 100),
|
|
274
53
|
description: message,
|
|
275
|
-
status:
|
|
54
|
+
status: 'ai_responding',
|
|
276
55
|
data: {
|
|
277
|
-
status: escalate ? 'human_assigned' : 'ai_responding',
|
|
278
56
|
aim_triage_agent_id: triageAgentId,
|
|
279
|
-
aim_confidence_threshold: promptConfig.confidence_threshold || CONFIDENCE_THRESHOLD,
|
|
280
|
-
aim_confidence_at_response: envelope.confidence,
|
|
281
|
-
aim_escalation_reason: escalate ? envelope.escalation_reason : 'none'
|
|
282
57
|
}
|
|
283
58
|
})
|
|
284
59
|
if (!ticket?.id) throw new Error('Failed to create ticket')
|
|
285
|
-
const ticketId = ticket.id
|
|
286
60
|
|
|
287
|
-
// Create thread
|
|
288
|
-
|
|
61
|
+
// 2. Create the external thread wired to the triage agent + prompt config.
|
|
62
|
+
// agent-runner resolves agent & config from thread.data.agent_id / prompt_config_id.
|
|
63
|
+
const threadRecord = await create(ctx, {
|
|
289
64
|
entity: 'threads',
|
|
290
|
-
type_id:
|
|
65
|
+
type_id: thread,
|
|
291
66
|
target_type: 'items',
|
|
292
|
-
target_id:
|
|
67
|
+
target_id: ticket.id,
|
|
293
68
|
visibility: 'external',
|
|
294
69
|
status: 'active',
|
|
295
|
-
data: {
|
|
70
|
+
data: {
|
|
71
|
+
agent_id: triageAgentId,
|
|
72
|
+
prompt_config_id: promptConfigId,
|
|
73
|
+
}
|
|
296
74
|
})
|
|
297
|
-
if (!
|
|
298
|
-
const threadId = thread.id
|
|
299
|
-
|
|
300
|
-
const publicContent = escalate
|
|
301
|
-
? "We're looking into this and will have a human response shortly."
|
|
302
|
-
: envelope.public_response
|
|
75
|
+
if (!threadRecord?.id) throw new Error('Failed to create thread')
|
|
303
76
|
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
type_id: typeIds.message,
|
|
308
|
-
thread_id: threadId,
|
|
309
|
-
person_id,
|
|
310
|
-
content: message,
|
|
311
|
-
direction: 'inbound',
|
|
312
|
-
visibility: 'public',
|
|
313
|
-
sequence: 1,
|
|
314
|
-
data: { message_type: 'human' }
|
|
315
|
-
})
|
|
316
|
-
if (!customerMsg?.id) throw new Error('Failed to save customer message')
|
|
77
|
+
// 3. Run the agent — saves user message, runs RAG+inference, handles escalation,
|
|
78
|
+
// saves agent message, emits audit. Returns the persisted agent message row.
|
|
79
|
+
const agentMsg = await runAgent(threadRecord.id, message, ctx)
|
|
317
80
|
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
type_id: typeIds.message,
|
|
321
|
-
thread_id: threadId,
|
|
322
|
-
content: publicContent,
|
|
323
|
-
direction: 'outbound',
|
|
324
|
-
visibility: 'public',
|
|
325
|
-
sequence: 2,
|
|
326
|
-
data: { message_type: 'agent', agent_id: triageAgentId, confidence: envelope.confidence, escalated: escalate }
|
|
327
|
-
})
|
|
328
|
-
if (!publicMsg?.id) throw new Error('Failed to save public AI message')
|
|
81
|
+
const confidence = agentMsg?.data?.confidence ?? 0
|
|
82
|
+
const escalated = agentMsg?.data?.escalated ?? false
|
|
329
83
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
direction: 'outbound',
|
|
336
|
-
visibility: 'internal',
|
|
337
|
-
sequence: 3,
|
|
84
|
+
// 4. Backfill suggested title if agent produced one, and stamp confidence/status
|
|
85
|
+
const suggestedTitle = agentMsg?.data?.suggested_title || message.slice(0, 80)
|
|
86
|
+
await adminDataUpdate(ctx, 'items', ticket.id, {
|
|
87
|
+
title: suggestedTitle,
|
|
88
|
+
status: escalated ? 'human_assigned' : 'ai_responding',
|
|
338
89
|
data: {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
prompt_sent: [{ role: 'system', content: systemPrompt }, { role: 'user', content: message }],
|
|
343
|
-
kb_sources_retrieved: kbSources.map((s) => ({ id: s.document_id, content: s.content?.slice(0, 100), relevance: null })),
|
|
344
|
-
prior_tickets_retrieved: priorTickets.map((t) => ({ id: t.id, title: t.title })),
|
|
345
|
-
ai_raw_response: envelope,
|
|
346
|
-
confidence: envelope.confidence,
|
|
347
|
-
confidence_reasoning: envelope.confidence_reasoning,
|
|
348
|
-
escalation_decision: escalate ? envelope.escalation_reason : 'none',
|
|
349
|
-
model, temperature, latency_ms, token_usage, suggested_title: suggestedTitle
|
|
350
|
-
}
|
|
90
|
+
aim_triage_agent_id: triageAgentId,
|
|
91
|
+
aim_confidence_at_response: confidence,
|
|
92
|
+
aim_escalation_reason: escalated ? 'low_confidence' : 'none',
|
|
351
93
|
}
|
|
352
94
|
})
|
|
353
|
-
if (!internalMsg?.id) throw new Error('Failed to save internal AI message')
|
|
354
95
|
|
|
355
96
|
return {
|
|
356
|
-
ticketId,
|
|
357
|
-
threadId,
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
escalated: escalate,
|
|
363
|
-
escalation_reason: escalate ? envelope.escalation_reason : 'none',
|
|
364
|
-
suggested_title: suggestedTitle
|
|
97
|
+
ticketId: ticket.id,
|
|
98
|
+
threadId: threadRecord.id,
|
|
99
|
+
agentMessageId: agentMsg?.id,
|
|
100
|
+
content: agentMsg?.content || '',
|
|
101
|
+
confidence,
|
|
102
|
+
escalated,
|
|
365
103
|
}
|
|
366
104
|
}
|
|
367
105
|
|
|
368
|
-
// ─── ACTION: REPLY
|
|
106
|
+
// ─── ACTION: REPLY ────────────────────────────────────────────────────────────
|
|
369
107
|
|
|
370
|
-
async function handleReply(
|
|
371
|
-
|
|
372
|
-
|
|
108
|
+
async function handleReply(
|
|
109
|
+
ctx: any,
|
|
110
|
+
body: any,
|
|
111
|
+
_ids: { supportTicket: string; thread: string; triageAgentId: string; promptConfigId: string }
|
|
112
|
+
) {
|
|
113
|
+
const { message, ticket_id, thread_id } = body
|
|
373
114
|
const person_id = ctx.principal?.id as string
|
|
374
|
-
if (!message
|
|
375
|
-
if (!
|
|
376
|
-
|
|
377
|
-
const { triageAgentId, promptConfigId } = typeIds
|
|
378
|
-
|
|
379
|
-
// Load agent + prompt config (config tables — direct reads are fine)
|
|
380
|
-
const [{ data: agent }, { data: promptConfig }] = await Promise.all([
|
|
381
|
-
adminDb.from('ai_agents').select('*').eq('id', triageAgentId).single(),
|
|
382
|
-
adminDb.from('prompt_configs').select('*').eq('id', promptConfigId).single()
|
|
383
|
-
])
|
|
384
|
-
if (!agent || !promptConfig) throw new Error('Triage agent configuration not found')
|
|
385
|
-
|
|
386
|
-
const model = agent.model_config?.model || process.env.LLM_DEFAULT_MODEL || 'gpt-4o'
|
|
387
|
-
const temperature = agent.model_config?.temperature ?? 0.7
|
|
388
|
-
|
|
389
|
-
const history = await getConversationHistory(thread_id)
|
|
390
|
-
const turnNumber = history.filter((m) => m.direction === 'inbound').length + 1
|
|
391
|
-
|
|
392
|
-
const { count: msgCount } = await adminDb
|
|
393
|
-
.from('messages')
|
|
394
|
-
.select('id', { count: 'exact', head: true })
|
|
395
|
-
.eq('thread_id', thread_id)
|
|
396
|
-
const nextSeq = (msgCount || 0) + 1
|
|
397
|
-
|
|
398
|
-
// Post customer message first (shows immediately)
|
|
399
|
-
const customerMsg = await create(ctx, {
|
|
400
|
-
entity: 'messages',
|
|
401
|
-
type_id: typeIds.message,
|
|
402
|
-
thread_id,
|
|
403
|
-
person_id,
|
|
404
|
-
content: message,
|
|
405
|
-
direction: 'inbound',
|
|
406
|
-
visibility: 'public',
|
|
407
|
-
sequence: nextSeq,
|
|
408
|
-
data: { message_type: 'human' }
|
|
409
|
-
})
|
|
410
|
-
if (!customerMsg?.id) throw new Error('Failed to save customer message')
|
|
411
|
-
|
|
412
|
-
// Run AI with full history
|
|
413
|
-
const [kbSources, priorTickets] = await Promise.all([
|
|
414
|
-
searchKB(message, account_id),
|
|
415
|
-
getPriorTickets(account_id, person_id, typeIds.supportTicket)
|
|
416
|
-
])
|
|
417
|
-
const systemPrompt = buildSystemPrompt(
|
|
418
|
-
agent.system_prompt,
|
|
419
|
-
promptConfig.context_template.replace('{{user_message}}', message),
|
|
420
|
-
kbSources,
|
|
421
|
-
priorTickets,
|
|
422
|
-
history
|
|
423
|
-
)
|
|
424
|
-
const promptMessages: Array<{ role: 'user' | 'assistant'; content: string }> = [
|
|
425
|
-
...history.map((m) => ({ role: (m.direction === 'inbound' ? 'user' : 'assistant') as 'user' | 'assistant', content: m.content })),
|
|
426
|
-
{ role: 'user', content: message }
|
|
427
|
-
]
|
|
428
|
-
const { envelope, latency_ms, token_usage } = await callOpenAI(systemPrompt, promptMessages, model, temperature)
|
|
429
|
-
|
|
430
|
-
const escalate = envelope.escalate || envelope.confidence < (promptConfig.confidence_threshold || CONFIDENCE_THRESHOLD)
|
|
431
|
-
const publicContent = escalate
|
|
432
|
-
? "We're looking into this and will have a human response shortly."
|
|
433
|
-
: envelope.public_response
|
|
115
|
+
if (!message) throw new Error('message is required')
|
|
116
|
+
if (!ticket_id) throw new Error('ticket_id is required')
|
|
117
|
+
if (!thread_id) throw new Error('thread_id is required')
|
|
434
118
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
thread_id,
|
|
439
|
-
content: publicContent,
|
|
440
|
-
direction: 'outbound',
|
|
441
|
-
visibility: 'public',
|
|
442
|
-
sequence: nextSeq + 1,
|
|
443
|
-
data: { message_type: 'agent', agent_id: triageAgentId, confidence: envelope.confidence, escalated: escalate }
|
|
444
|
-
})
|
|
445
|
-
if (!publicMsg?.id) throw new Error('Failed to save public AI message')
|
|
119
|
+
// agent-runner saves the user message then runs inference on the existing thread.
|
|
120
|
+
// Thread.data.agent_id / prompt_config_id are already set from ticket creation.
|
|
121
|
+
const agentMsg = await runAgent(thread_id, message, ctx)
|
|
446
122
|
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
type_id: typeIds.message,
|
|
450
|
-
thread_id,
|
|
451
|
-
content: `[AI Internal] Turn ${turnNumber} — Confidence: ${envelope.confidence.toFixed(2)} — ${envelope.confidence_reasoning}`,
|
|
452
|
-
direction: 'outbound',
|
|
453
|
-
visibility: 'internal',
|
|
454
|
-
sequence: nextSeq + 2,
|
|
455
|
-
data: {
|
|
456
|
-
message_type: 'agent_internal',
|
|
457
|
-
ai_internal: {
|
|
458
|
-
turn: turnNumber,
|
|
459
|
-
prompt_sent: [{ role: 'system', content: systemPrompt }, ...promptMessages],
|
|
460
|
-
kb_sources_retrieved: kbSources.map((s) => ({ id: s.document_id, content: s.content?.slice(0, 100), relevance: null })),
|
|
461
|
-
prior_tickets_retrieved: priorTickets.map((t) => ({ id: t.id, title: t.title })),
|
|
462
|
-
ai_raw_response: envelope,
|
|
463
|
-
confidence: envelope.confidence,
|
|
464
|
-
confidence_reasoning: envelope.confidence_reasoning,
|
|
465
|
-
escalation_decision: escalate ? envelope.escalation_reason : 'none',
|
|
466
|
-
model, temperature, latency_ms, token_usage
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
})
|
|
470
|
-
if (!internalMsg?.id) throw new Error('Failed to save internal AI message')
|
|
123
|
+
const confidence = agentMsg?.data?.confidence ?? 0
|
|
124
|
+
const escalated = agentMsg?.data?.escalated ?? false
|
|
471
125
|
|
|
472
|
-
|
|
473
|
-
if (escalate) {
|
|
126
|
+
if (escalated) {
|
|
474
127
|
const { data: currentTicket } = await adminDb
|
|
475
|
-
.from('items')
|
|
476
|
-
.select('data')
|
|
477
|
-
.eq('id', ticket_id)
|
|
478
|
-
.single()
|
|
479
|
-
|
|
128
|
+
.from('items').select('data').eq('id', ticket_id).single()
|
|
480
129
|
await adminDataUpdate(ctx, 'items', ticket_id, {
|
|
481
130
|
status: 'human_assigned',
|
|
482
131
|
data: {
|
|
483
132
|
...(currentTicket?.data || {}),
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
aim_escalation_reason: envelope.escalation_reason
|
|
133
|
+
aim_confidence_at_response: confidence,
|
|
134
|
+
aim_escalation_reason: 'low_confidence',
|
|
487
135
|
}
|
|
488
136
|
})
|
|
489
137
|
}
|
|
@@ -491,12 +139,10 @@ async function handleReply(ctx: any, body: any, typeIds: TypeIds): Promise<Triag
|
|
|
491
139
|
return {
|
|
492
140
|
ticketId: ticket_id,
|
|
493
141
|
threadId: thread_id,
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
escalated: escalate,
|
|
499
|
-
escalation_reason: escalate ? envelope.escalation_reason : 'none'
|
|
142
|
+
agentMessageId: agentMsg?.id,
|
|
143
|
+
content: agentMsg?.content || '',
|
|
144
|
+
confidence,
|
|
145
|
+
escalated,
|
|
500
146
|
}
|
|
501
147
|
}
|
|
502
148
|
|
|
@@ -505,19 +151,17 @@ async function handleReply(ctx: any, body: any, typeIds: TypeIds): Promise<Triag
|
|
|
505
151
|
export const handler = createHandler(async (ctx, body) => {
|
|
506
152
|
const action = (ctx as any).query?.action
|
|
507
153
|
|
|
508
|
-
|
|
509
|
-
const [supportTicket, thread, message, triageAgentId, promptConfigId] = await Promise.all([
|
|
154
|
+
const [supportTicket, thread, triageAgentId, promptConfigId] = await Promise.all([
|
|
510
155
|
resolveTypeId('item', 'support_ticket'),
|
|
511
|
-
resolveTypeId('thread', '
|
|
512
|
-
resolveTypeId('message', 'message'),
|
|
156
|
+
resolveTypeId('thread', 'support_thread'),
|
|
513
157
|
resolveAgentId('Support Triage Agent'),
|
|
514
158
|
resolvePromptConfigId('support_triage'),
|
|
515
159
|
])
|
|
516
|
-
const
|
|
160
|
+
const ids = { supportTicket, thread, triageAgentId, promptConfigId }
|
|
517
161
|
|
|
518
162
|
switch (action) {
|
|
519
|
-
case 'new_ticket': return handleNewTicket(ctx, body,
|
|
520
|
-
case 'reply': return handleReply(ctx, body,
|
|
163
|
+
case 'new_ticket': return handleNewTicket(ctx, body, ids)
|
|
164
|
+
case 'reply': return handleReply(ctx, body, ids)
|
|
521
165
|
default: throw new Error(`Unknown action: ${action}. Use 'new_ticket' or 'reply'.`)
|
|
522
166
|
}
|
|
523
167
|
})
|
|
@@ -35,19 +35,39 @@ async function fetchJSON(path: string, options?: RequestInit) {
|
|
|
35
35
|
return json.data
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
type ThreadDomain = 'support' | 'community' | 'generic'
|
|
39
|
+
|
|
40
|
+
function getThreadTypeSlug(domain: ThreadDomain) {
|
|
41
|
+
if (domain === 'support') return 'support_thread'
|
|
42
|
+
if (domain === 'community') return 'community_thread'
|
|
43
|
+
return 'thread'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getMessageTypeSlug(domain: ThreadDomain) {
|
|
47
|
+
if (domain === 'support') return 'support_reply'
|
|
48
|
+
if (domain === 'community') return 'community_reply'
|
|
49
|
+
return 'message'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function usePortalThread(targetType: string, targetId: string | null, domain: ThreadDomain = 'generic') {
|
|
39
53
|
const [thread, setThread] = useState<PortalThread | null>(null)
|
|
40
54
|
const [messages, setMessages] = useState<PortalMessage[]>([])
|
|
41
55
|
const [loading, setLoading] = useState(false)
|
|
42
56
|
const [error, setError] = useState<string | null>(null)
|
|
43
57
|
|
|
58
|
+
const threadTypeSlug = getThreadTypeSlug(domain)
|
|
59
|
+
const messageTypeSlug = getMessageTypeSlug(domain)
|
|
60
|
+
|
|
44
61
|
const load = useCallback(async () => {
|
|
45
62
|
if (!targetId) return
|
|
46
63
|
setLoading(true)
|
|
47
64
|
setError(null)
|
|
48
65
|
try {
|
|
66
|
+
const threadTypeId = await getTypeIdAsync(threadTypeSlug)
|
|
67
|
+
if (!threadTypeId) throw new Error(`${threadTypeSlug} type not found`)
|
|
68
|
+
|
|
49
69
|
const threads = await fetchJSON(
|
|
50
|
-
`/.netlify/functions/admin-data?entity=threads&target_type=${targetType}&target_id=${targetId}`
|
|
70
|
+
`/.netlify/functions/admin-data?entity=threads&target_type=${targetType}&target_id=${targetId}&type_id=${threadTypeId}`
|
|
51
71
|
)
|
|
52
72
|
const found: PortalThread | null = Array.isArray(threads) ? (threads[0] ?? null) : null
|
|
53
73
|
setThread(found)
|
|
@@ -64,7 +84,7 @@ export function usePortalThread(targetType: string, targetId: string | null) {
|
|
|
64
84
|
} finally {
|
|
65
85
|
setLoading(false)
|
|
66
86
|
}
|
|
67
|
-
}, [targetType, targetId])
|
|
87
|
+
}, [targetType, targetId, threadTypeSlug])
|
|
68
88
|
|
|
69
89
|
useEffect(() => { load() }, [load])
|
|
70
90
|
|
|
@@ -72,8 +92,8 @@ export function usePortalThread(targetType: string, targetId: string | null) {
|
|
|
72
92
|
let activeThread = thread
|
|
73
93
|
|
|
74
94
|
if (!activeThread?.id) {
|
|
75
|
-
const threadTypeId = await getTypeIdAsync(
|
|
76
|
-
if (!threadTypeId) throw new Error(
|
|
95
|
+
const threadTypeId = await getTypeIdAsync(threadTypeSlug)
|
|
96
|
+
if (!threadTypeId) throw new Error(`${threadTypeSlug} type not found`)
|
|
77
97
|
|
|
78
98
|
activeThread = await fetchJSON('/.netlify/functions/admin-data', {
|
|
79
99
|
method: 'POST',
|
|
@@ -88,8 +108,8 @@ export function usePortalThread(targetType: string, targetId: string | null) {
|
|
|
88
108
|
setThread(activeThread)
|
|
89
109
|
}
|
|
90
110
|
|
|
91
|
-
const messageTypeId = await getTypeIdAsync(
|
|
92
|
-
if (!messageTypeId) throw new Error(
|
|
111
|
+
const messageTypeId = await getTypeIdAsync(messageTypeSlug)
|
|
112
|
+
if (!messageTypeId) throw new Error(`${messageTypeSlug} type not found`)
|
|
93
113
|
|
|
94
114
|
// Calculate next sequence based on current messages to avoid stale state
|
|
95
115
|
const nextSequence = messages.reduce((max, m) => Math.max(max, m.sequence || 0), 0) + 1
|
|
@@ -107,7 +127,7 @@ export function usePortalThread(targetType: string, targetId: string | null) {
|
|
|
107
127
|
})
|
|
108
128
|
setMessages((prev) => [...prev, msg])
|
|
109
129
|
return msg
|
|
110
|
-
}, [thread, targetType, targetId, messages])
|
|
130
|
+
}, [thread, targetType, targetId, messages, threadTypeSlug, messageTypeSlug])
|
|
111
131
|
|
|
112
132
|
return { thread, messages, loading, error, reply, refetch: load }
|
|
113
133
|
}
|
package/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "Portal",
|
|
3
3
|
"slug": "portal",
|
|
4
4
|
"description": "Self-service portal for customers to access tickets, knowledge base, courses, and community",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.21",
|
|
6
6
|
"app_type": "full",
|
|
7
7
|
"route_prefix": "/portal",
|
|
8
8
|
"required_roles": ["member", "member_admin"],
|
package/package.json
CHANGED
package/pages/CommunityPage.tsx
CHANGED
|
@@ -22,7 +22,7 @@ const CHANNEL_LABELS: Record<string, string> = {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function ThreadPane({ post }: { post: CommunityPost }) {
|
|
25
|
-
const { messages, loading, reply } = usePortalThread('items', post.id)
|
|
25
|
+
const { messages, loading, reply } = usePortalThread('items', post.id, 'community')
|
|
26
26
|
const [replyText, setReplyText] = useState('')
|
|
27
27
|
const [sending, setSending] = useState(false)
|
|
28
28
|
const { sendSignal } = usePortalSignal()
|
package/pages/TicketsPage.tsx
CHANGED
|
@@ -85,7 +85,7 @@ function AIResponseCard({ message, ticketId, messageId, onFeedback }: {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
function TicketThread({ ticketId, ticketStatus, onStatusChange }: TicketThreadProps) {
|
|
88
|
-
const { messages, loading, thread, refetch } = usePortalThread('items', ticketId)
|
|
88
|
+
const { messages, loading, thread, refetch } = usePortalThread('items', ticketId, 'support')
|
|
89
89
|
const [replyText, setReplyText] = useState('')
|
|
90
90
|
const [aiState, setAiState] = useState<'idle' | 'analyzing' | 'responded' | 'escalated'>('idle')
|
|
91
91
|
const { sendReply, loading: replying } = useTriageReply()
|
package/seed/types.json
CHANGED
|
@@ -1300,10 +1300,162 @@
|
|
|
1300
1300
|
}
|
|
1301
1301
|
},
|
|
1302
1302
|
{
|
|
1303
|
-
"kind": "
|
|
1303
|
+
"kind": "thread",
|
|
1304
|
+
"slug": "support_thread",
|
|
1305
|
+
"name": "Support Thread",
|
|
1306
|
+
"description": "Customer-visible conversation thread attached to a support ticket",
|
|
1307
|
+
"icon": "message-square",
|
|
1308
|
+
"color": "#EF4444",
|
|
1309
|
+
"ownership": "tenant",
|
|
1310
|
+
"is_active": true,
|
|
1311
|
+
"validation_schema": {},
|
|
1312
|
+
"design_schema": {
|
|
1313
|
+
"scope": "account",
|
|
1314
|
+
"views": {
|
|
1315
|
+
"default_list": {
|
|
1316
|
+
"type": "list",
|
|
1317
|
+
"label": "Support Threads",
|
|
1318
|
+
"fields": {
|
|
1319
|
+
"title": { "sortable": true, "display_type": "text" },
|
|
1320
|
+
"target_type": { "sortable": true, "display_type": "text" },
|
|
1321
|
+
"visibility": { "sortable": true, "display_type": "badge" },
|
|
1322
|
+
"status": { "sortable": true, "display_type": "badge" }
|
|
1323
|
+
},
|
|
1324
|
+
"display": "table"
|
|
1325
|
+
},
|
|
1326
|
+
"default_detail": {
|
|
1327
|
+
"type": "detail",
|
|
1328
|
+
"label": "Support Thread",
|
|
1329
|
+
"sections": [
|
|
1330
|
+
{ "title": "Overview", "fields": ["title", "target_type", "target_id", "visibility", "status"] }
|
|
1331
|
+
]
|
|
1332
|
+
}
|
|
1333
|
+
},
|
|
1334
|
+
"fields": {
|
|
1335
|
+
"title": { "data_type": "text", "label": "Title", "required": false, "system": true },
|
|
1336
|
+
"target_type": { "data_type": "text", "label": "Target Type", "required": true, "system": true },
|
|
1337
|
+
"target_id": { "data_type": "uuid", "label": "Target ID", "required": true, "system": true },
|
|
1338
|
+
"visibility": { "data_type": "text", "label": "Visibility", "required": true, "system": true },
|
|
1339
|
+
"status": { "data_type": "text", "label": "Status", "required": true, "system": true },
|
|
1340
|
+
"is_active": { "data_type": "boolean", "label": "Active", "required": true, "system": true },
|
|
1341
|
+
"created_at": { "data_type": "datetime", "label": "Created", "required": false, "system": true, "readonly": true },
|
|
1342
|
+
"updated_at": { "data_type": "datetime", "label": "Updated", "required": false, "system": true, "readonly": true }
|
|
1343
|
+
},
|
|
1344
|
+
"record_permissions": {
|
|
1345
|
+
"system_admin": ["create", "read", "update", "delete"],
|
|
1346
|
+
"support_admin": ["create", "read", "update"],
|
|
1347
|
+
"support": ["create", "read", "update"],
|
|
1348
|
+
"member_admin": ["create", "read", "update"],
|
|
1349
|
+
"member": ["create", "read", "update"]
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
},
|
|
1353
|
+
{
|
|
1354
|
+
"kind": "message",
|
|
1355
|
+
"slug": "support_reply",
|
|
1356
|
+
"name": "Support Reply",
|
|
1357
|
+
"description": "Customer or agent message within a support thread",
|
|
1358
|
+
"icon": "message-circle",
|
|
1359
|
+
"color": "#EF4444",
|
|
1360
|
+
"ownership": "tenant",
|
|
1361
|
+
"is_active": true,
|
|
1362
|
+
"validation_schema": {},
|
|
1363
|
+
"design_schema": {
|
|
1364
|
+
"scope": "account",
|
|
1365
|
+
"views": {
|
|
1366
|
+
"default_list": {
|
|
1367
|
+
"type": "list",
|
|
1368
|
+
"label": "Support Replies",
|
|
1369
|
+
"fields": {
|
|
1370
|
+
"content": { "sortable": false, "display_type": "text" },
|
|
1371
|
+
"direction": { "sortable": true, "display_type": "badge" },
|
|
1372
|
+
"visibility": { "sortable": true, "display_type": "badge" },
|
|
1373
|
+
"created_at": { "sortable": true, "display_type": "datetime" }
|
|
1374
|
+
},
|
|
1375
|
+
"display": "table"
|
|
1376
|
+
},
|
|
1377
|
+
"default_detail": {
|
|
1378
|
+
"type": "detail",
|
|
1379
|
+
"label": "Support Reply",
|
|
1380
|
+
"sections": [
|
|
1381
|
+
{ "title": "Content", "fields": ["content", "direction", "visibility"] }
|
|
1382
|
+
]
|
|
1383
|
+
}
|
|
1384
|
+
},
|
|
1385
|
+
"fields": {
|
|
1386
|
+
"content": { "data_type": "textarea", "label": "Content", "required": true, "system": true },
|
|
1387
|
+
"direction": { "data_type": "text", "label": "Direction", "required": true, "system": true },
|
|
1388
|
+
"sequence": { "data_type": "number", "label": "Sequence", "required": true, "system": true },
|
|
1389
|
+
"visibility": { "data_type": "text", "label": "Visibility", "required": true, "system": true },
|
|
1390
|
+
"is_active": { "data_type": "boolean", "label": "Active", "required": true, "system": true },
|
|
1391
|
+
"created_at": { "data_type": "datetime", "label": "Created", "required": false, "system": true, "readonly": true },
|
|
1392
|
+
"updated_at": { "data_type": "datetime", "label": "Updated", "required": false, "system": true, "readonly": true }
|
|
1393
|
+
},
|
|
1394
|
+
"record_permissions": {
|
|
1395
|
+
"system_admin": ["create", "read", "update", "delete"],
|
|
1396
|
+
"support_admin": ["create", "read", "update"],
|
|
1397
|
+
"support": ["create", "read", "update"],
|
|
1398
|
+
"member_admin": ["create", "read", "update"],
|
|
1399
|
+
"member": ["create", "read", "update"]
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
},
|
|
1403
|
+
{
|
|
1404
|
+
"kind": "thread",
|
|
1405
|
+
"slug": "community_thread",
|
|
1406
|
+
"name": "Community Thread",
|
|
1407
|
+
"description": "Public discussion thread attached to a community post",
|
|
1408
|
+
"icon": "users",
|
|
1409
|
+
"color": "#6366F1",
|
|
1410
|
+
"ownership": "tenant",
|
|
1411
|
+
"is_active": true,
|
|
1412
|
+
"validation_schema": {},
|
|
1413
|
+
"design_schema": {
|
|
1414
|
+
"scope": "platform",
|
|
1415
|
+
"views": {
|
|
1416
|
+
"default_list": {
|
|
1417
|
+
"type": "list",
|
|
1418
|
+
"label": "Community Threads",
|
|
1419
|
+
"fields": {
|
|
1420
|
+
"title": { "sortable": true, "display_type": "text" },
|
|
1421
|
+
"target_type": { "sortable": true, "display_type": "text" },
|
|
1422
|
+
"visibility": { "sortable": true, "display_type": "badge" },
|
|
1423
|
+
"status": { "sortable": true, "display_type": "badge" }
|
|
1424
|
+
},
|
|
1425
|
+
"display": "table"
|
|
1426
|
+
},
|
|
1427
|
+
"default_detail": {
|
|
1428
|
+
"type": "detail",
|
|
1429
|
+
"label": "Community Thread",
|
|
1430
|
+
"sections": [
|
|
1431
|
+
{ "title": "Overview", "fields": ["title", "target_type", "target_id", "visibility", "status"] }
|
|
1432
|
+
]
|
|
1433
|
+
}
|
|
1434
|
+
},
|
|
1435
|
+
"fields": {
|
|
1436
|
+
"title": { "data_type": "text", "label": "Title", "required": false, "system": true },
|
|
1437
|
+
"target_type": { "data_type": "text", "label": "Target Type", "required": true, "system": true },
|
|
1438
|
+
"target_id": { "data_type": "uuid", "label": "Target ID", "required": true, "system": true },
|
|
1439
|
+
"visibility": { "data_type": "text", "label": "Visibility", "required": true, "system": true },
|
|
1440
|
+
"status": { "data_type": "text", "label": "Status", "required": true, "system": true },
|
|
1441
|
+
"is_active": { "data_type": "boolean", "label": "Active", "required": true, "system": true },
|
|
1442
|
+
"created_at": { "data_type": "datetime", "label": "Created", "required": false, "system": true, "readonly": true },
|
|
1443
|
+
"updated_at": { "data_type": "datetime", "label": "Updated", "required": false, "system": true, "readonly": true }
|
|
1444
|
+
},
|
|
1445
|
+
"record_permissions": {
|
|
1446
|
+
"system_admin": ["create", "read", "update", "delete"],
|
|
1447
|
+
"support_admin": ["create", "read", "update"],
|
|
1448
|
+
"support": ["create", "read", "update"],
|
|
1449
|
+
"member_admin": ["create", "read", "update"],
|
|
1450
|
+
"member": ["create", "read", "update"]
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
},
|
|
1454
|
+
{
|
|
1455
|
+
"kind": "message",
|
|
1304
1456
|
"slug": "community_reply",
|
|
1305
1457
|
"name": "Community Reply",
|
|
1306
|
-
"description": "
|
|
1458
|
+
"description": "Public reply within a community thread",
|
|
1307
1459
|
"icon": "message-circle",
|
|
1308
1460
|
"color": "#22C55E",
|
|
1309
1461
|
"ownership": "tenant",
|
|
@@ -1316,18 +1468,10 @@
|
|
|
1316
1468
|
"type": "list",
|
|
1317
1469
|
"label": "Community Replies",
|
|
1318
1470
|
"fields": {
|
|
1319
|
-
"
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
}
|
|
1323
|
-
"parent_post_id": {
|
|
1324
|
-
"sortable": true,
|
|
1325
|
-
"display_type": "text"
|
|
1326
|
-
},
|
|
1327
|
-
"created_at": {
|
|
1328
|
-
"sortable": true,
|
|
1329
|
-
"display_type": "datetime"
|
|
1330
|
-
}
|
|
1471
|
+
"content": { "sortable": false, "display_type": "text" },
|
|
1472
|
+
"direction": { "sortable": true, "display_type": "badge" },
|
|
1473
|
+
"visibility": { "sortable": true, "display_type": "badge" },
|
|
1474
|
+
"created_at": { "sortable": true, "display_type": "datetime" }
|
|
1331
1475
|
},
|
|
1332
1476
|
"display": "table"
|
|
1333
1477
|
},
|
|
@@ -1335,73 +1479,26 @@
|
|
|
1335
1479
|
"type": "detail",
|
|
1336
1480
|
"label": "Community Reply",
|
|
1337
1481
|
"sections": [
|
|
1338
|
-
{
|
|
1339
|
-
"title": "Content",
|
|
1340
|
-
"fields": [
|
|
1341
|
-
"title",
|
|
1342
|
-
"content"
|
|
1343
|
-
]
|
|
1344
|
-
},
|
|
1345
|
-
{
|
|
1346
|
-
"title": "Context",
|
|
1347
|
-
"fields": [
|
|
1348
|
-
"parent_post_id"
|
|
1349
|
-
]
|
|
1350
|
-
}
|
|
1482
|
+
{ "title": "Content", "fields": ["content", "direction", "visibility"] }
|
|
1351
1483
|
]
|
|
1352
1484
|
}
|
|
1353
1485
|
},
|
|
1354
1486
|
"fields": {
|
|
1355
|
-
"
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
},
|
|
1360
|
-
"
|
|
1361
|
-
|
|
1362
|
-
"required": true,
|
|
1363
|
-
"data_type": "textarea"
|
|
1364
|
-
},
|
|
1365
|
-
"parent_post_id": {
|
|
1366
|
-
"label": "Parent Post ID",
|
|
1367
|
-
"required": true,
|
|
1368
|
-
"data_type": "text"
|
|
1369
|
-
},
|
|
1370
|
-
"is_solution": {
|
|
1371
|
-
"label": "Is Solution",
|
|
1372
|
-
"data_type": "boolean",
|
|
1373
|
-
"default": false
|
|
1374
|
-
}
|
|
1487
|
+
"content": { "data_type": "textarea", "label": "Content", "required": true, "system": true },
|
|
1488
|
+
"direction": { "data_type": "text", "label": "Direction", "required": true, "system": true },
|
|
1489
|
+
"sequence": { "data_type": "number", "label": "Sequence", "required": true, "system": true },
|
|
1490
|
+
"visibility": { "data_type": "text", "label": "Visibility", "required": true, "system": true },
|
|
1491
|
+
"is_active": { "data_type": "boolean", "label": "Active", "required": true, "system": true },
|
|
1492
|
+
"created_at": { "data_type": "datetime", "label": "Created", "required": false, "system": true, "readonly": true },
|
|
1493
|
+
"updated_at": { "data_type": "datetime", "label": "Updated", "required": false, "system": true, "readonly": true }
|
|
1375
1494
|
},
|
|
1376
1495
|
"record_permissions": {
|
|
1377
|
-
"system_admin": [
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
"support_admin": [
|
|
1384
|
-
"create",
|
|
1385
|
-
"read",
|
|
1386
|
-
"update"
|
|
1387
|
-
],
|
|
1388
|
-
"support": [
|
|
1389
|
-
"create",
|
|
1390
|
-
"read",
|
|
1391
|
-
"update"
|
|
1392
|
-
],
|
|
1393
|
-
"member_admin": [
|
|
1394
|
-
"create",
|
|
1395
|
-
"read",
|
|
1396
|
-
"update"
|
|
1397
|
-
],
|
|
1398
|
-
"member": [
|
|
1399
|
-
"create",
|
|
1400
|
-
"read",
|
|
1401
|
-
"update"
|
|
1402
|
-
]
|
|
1403
|
-
},
|
|
1404
|
-
"functionality": null
|
|
1496
|
+
"system_admin": ["create", "read", "update", "delete"],
|
|
1497
|
+
"support_admin": ["create", "read", "update"],
|
|
1498
|
+
"support": ["create", "read", "update"],
|
|
1499
|
+
"member_admin": ["create", "read", "update"],
|
|
1500
|
+
"member": ["create", "read", "update"]
|
|
1501
|
+
}
|
|
1405
1502
|
}
|
|
1406
1503
|
}
|
|
1407
1504
|
]
|