@open-mercato/ai-assistant 0.6.2-develop.3461.1.605f31c2c9 → 0.6.2

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.
Files changed (63) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -2
  3. package/dist/modules/ai_assistant/acl.js +1 -0
  4. package/dist/modules/ai_assistant/acl.js.map +2 -2
  5. package/dist/modules/ai_assistant/api/ai/chat/route.js +197 -2
  6. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  7. package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/route.js +272 -0
  8. package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/route.js.map +7 -0
  9. package/dist/modules/ai_assistant/api/ai/conversations/import/route.js +108 -0
  10. package/dist/modules/ai_assistant/api/ai/conversations/import/route.js.map +7 -0
  11. package/dist/modules/ai_assistant/api/ai/conversations/route.js +207 -0
  12. package/dist/modules/ai_assistant/api/ai/conversations/route.js.map +7 -0
  13. package/dist/modules/ai_assistant/data/entities/AiChatConversation.js +5 -0
  14. package/dist/modules/ai_assistant/data/entities/AiChatConversation.js.map +7 -0
  15. package/dist/modules/ai_assistant/data/entities/AiChatConversationParticipant.js +5 -0
  16. package/dist/modules/ai_assistant/data/entities/AiChatConversationParticipant.js.map +7 -0
  17. package/dist/modules/ai_assistant/data/entities/AiChatMessage.js +5 -0
  18. package/dist/modules/ai_assistant/data/entities/AiChatMessage.js.map +7 -0
  19. package/dist/modules/ai_assistant/data/entities.js +200 -0
  20. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  21. package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js +448 -0
  22. package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js.map +7 -0
  23. package/dist/modules/ai_assistant/data/validators.js +72 -0
  24. package/dist/modules/ai_assistant/data/validators.js.map +7 -0
  25. package/dist/modules/ai_assistant/i18n/de.json +3 -0
  26. package/dist/modules/ai_assistant/i18n/en.json +3 -0
  27. package/dist/modules/ai_assistant/i18n/es.json +3 -0
  28. package/dist/modules/ai_assistant/i18n/pl.json +3 -0
  29. package/dist/modules/ai_assistant/lib/conversation-storage.js +43 -0
  30. package/dist/modules/ai_assistant/lib/conversation-storage.js.map +7 -0
  31. package/dist/modules/ai_assistant/migrations/Migration20260518092853_ai_assistant.js +28 -0
  32. package/dist/modules/ai_assistant/migrations/Migration20260518092853_ai_assistant.js.map +7 -0
  33. package/dist/modules/ai_assistant/setup.js +1 -0
  34. package/dist/modules/ai_assistant/setup.js.map +2 -2
  35. package/generated/entities/ai_chat_conversation/index.ts +15 -0
  36. package/generated/entities/ai_chat_conversation_participant/index.ts +9 -0
  37. package/generated/entities/ai_chat_message/index.ts +16 -0
  38. package/generated/entities.ids.generated.ts +4 -1
  39. package/generated/entity-fields-registry.ts +46 -0
  40. package/jest.config.cjs +3 -1
  41. package/package.json +14 -15
  42. package/src/modules/ai_assistant/acl.ts +1 -0
  43. package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +107 -0
  44. package/src/modules/ai_assistant/api/ai/chat/route.ts +245 -1
  45. package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/route.ts +320 -0
  46. package/src/modules/ai_assistant/api/ai/conversations/__tests__/route.test.ts +93 -0
  47. package/src/modules/ai_assistant/api/ai/conversations/import/route.ts +122 -0
  48. package/src/modules/ai_assistant/api/ai/conversations/route.ts +241 -0
  49. package/src/modules/ai_assistant/data/entities/AiChatConversation.ts +2 -0
  50. package/src/modules/ai_assistant/data/entities/AiChatConversationParticipant.ts +2 -0
  51. package/src/modules/ai_assistant/data/entities/AiChatMessage.ts +2 -0
  52. package/src/modules/ai_assistant/data/entities.ts +255 -0
  53. package/src/modules/ai_assistant/data/repositories/AiChatConversationRepository.ts +597 -0
  54. package/src/modules/ai_assistant/data/repositories/__tests__/AiChatConversationRepository.test.ts +592 -0
  55. package/src/modules/ai_assistant/data/validators.ts +134 -0
  56. package/src/modules/ai_assistant/i18n/de.json +3 -0
  57. package/src/modules/ai_assistant/i18n/en.json +3 -0
  58. package/src/modules/ai_assistant/i18n/es.json +3 -0
  59. package/src/modules/ai_assistant/i18n/pl.json +3 -0
  60. package/src/modules/ai_assistant/lib/conversation-storage.ts +93 -0
  61. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +822 -0
  62. package/src/modules/ai_assistant/migrations/Migration20260518092853_ai_assistant.ts +39 -0
  63. package/src/modules/ai_assistant/setup.ts +1 -0
@@ -28,6 +28,7 @@ import {
28
28
  } from '../../../lib/model-allowlist'
29
29
  import { AiTenantModelAllowlistRepository } from '../../../data/repositories/AiTenantModelAllowlistRepository'
30
30
  import { AiAgentRuntimeOverrideRepository } from '../../../data/repositories/AiAgentRuntimeOverrideRepository'
31
+ import { createConversationStorage } from '../../../lib/conversation-storage'
31
32
  import type { EntityManager } from '@mikro-orm/postgresql'
32
33
 
33
34
  const MAX_MESSAGES = 100
@@ -35,8 +36,23 @@ const MAX_MESSAGES = 100
35
36
  const agentIdPattern = /^[a-z0-9_]+\.[a-z0-9_]+$/
36
37
 
37
38
  const chatMessageSchema = z.object({
39
+ id: z.string().min(1).max(128).optional(),
38
40
  role: z.enum(['user', 'assistant', 'system']),
39
41
  content: z.string(),
42
+ uiParts: z.array(z.unknown()).optional(),
43
+ files: z
44
+ .array(
45
+ z
46
+ .object({
47
+ id: z.string().optional(),
48
+ name: z.string().optional(),
49
+ type: z.string().optional(),
50
+ mimeType: z.string().optional(),
51
+ size: z.number().optional(),
52
+ })
53
+ .passthrough(),
54
+ )
55
+ .optional(),
40
56
  })
41
57
 
42
58
  const pageContextSchema = z
@@ -189,6 +205,204 @@ function statusForDenyCode(code: AgentPolicyDenyCode): number {
189
205
  }
190
206
  }
191
207
 
208
+ function extractDataPayload(eventBlock: string): string | null {
209
+ const dataLines = eventBlock
210
+ .split('\n')
211
+ .filter((line) => line.startsWith('data:'))
212
+ .map((line) => (line.startsWith('data: ') ? line.slice(6) : line.slice(5)))
213
+ if (dataLines.length === 0) return null
214
+ return dataLines.join('\n')
215
+ }
216
+
217
+ function extractUiPartsFromToolOutput(output: unknown): unknown[] {
218
+ let parsed = output
219
+ if (typeof output === 'string') {
220
+ const trimmed = output.trim()
221
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return []
222
+ try {
223
+ parsed = JSON.parse(trimmed) as unknown
224
+ } catch {
225
+ return []
226
+ }
227
+ }
228
+ if (!parsed || typeof parsed !== 'object') return []
229
+ const value = parsed as Record<string, unknown>
230
+ const parts: unknown[] = []
231
+ if (value.status === 'pending-confirmation' || value.status === 'awaiting-confirmation') {
232
+ const pendingActionId =
233
+ typeof value.pendingActionId === 'string' && value.pendingActionId.length > 0
234
+ ? value.pendingActionId
235
+ : null
236
+ if (pendingActionId) {
237
+ parts.push({
238
+ componentId: 'mutation-preview-card',
239
+ pendingActionId,
240
+ payload: {
241
+ pendingActionId,
242
+ expiresAt: typeof value.expiresAt === 'string' ? value.expiresAt : undefined,
243
+ agentId:
244
+ typeof value.agentId === 'string'
245
+ ? value.agentId
246
+ : typeof value.agent === 'string'
247
+ ? value.agent
248
+ : undefined,
249
+ toolName: typeof value.toolName === 'string' ? value.toolName : undefined,
250
+ },
251
+ })
252
+ }
253
+ }
254
+ if (value.uiPart && typeof value.uiPart === 'object') parts.push(value.uiPart)
255
+ if (Array.isArray(value.uiParts)) parts.push(...value.uiParts)
256
+ return parts
257
+ }
258
+
259
+ function extractAssistantSnapshot(
260
+ raw: string,
261
+ contentType: string | null,
262
+ ): { content: string; uiParts: unknown[] } {
263
+ if (!contentType?.includes('event-stream')) {
264
+ return { content: raw, uiParts: [] }
265
+ }
266
+ let content = ''
267
+ const uiParts: unknown[] = []
268
+ for (const block of raw.split('\n\n')) {
269
+ const data = extractDataPayload(block)
270
+ if (!data || data === '[DONE]') continue
271
+ try {
272
+ const parsed = JSON.parse(data) as Record<string, unknown>
273
+ if (parsed.type === 'text-delta' && typeof parsed.delta === 'string') {
274
+ content += parsed.delta
275
+ } else if (parsed.type === 'text' && typeof parsed.content === 'string') {
276
+ content += parsed.content
277
+ } else if (parsed.type === 'tool-output-available') {
278
+ uiParts.push(...extractUiPartsFromToolOutput(parsed.output))
279
+ }
280
+ } catch {
281
+ // Ignore SSE comments and malformed provider chunks.
282
+ }
283
+ }
284
+ return { content, uiParts }
285
+ }
286
+
287
+ async function persistChatTurnStart(input: {
288
+ container: Awaited<ReturnType<typeof createRequestContainer>>
289
+ tenantId: string | null | undefined
290
+ organizationId: string | null | undefined
291
+ userId: string
292
+ agentId: string
293
+ conversationId: string | null
294
+ pageContext?: Record<string, unknown>
295
+ messages: AiChatRequest['messages']
296
+ attachmentIds?: string[]
297
+ }): Promise<{ conversationId: string; userClientMessageId: string | null } | null> {
298
+ if (!input.tenantId || !input.conversationId) return null
299
+ const repo = createConversationStorage(input.container)
300
+ const ctx = {
301
+ tenantId: input.tenantId,
302
+ organizationId: input.organizationId ?? null,
303
+ userId: input.userId,
304
+ }
305
+ await repo.createOrGet(
306
+ {
307
+ conversationId: input.conversationId,
308
+ agentId: input.agentId,
309
+ pageContext: input.pageContext ?? null,
310
+ },
311
+ ctx,
312
+ )
313
+ const userMessage = [...input.messages].reverse().find((message) => message.role === 'user')
314
+ if (!userMessage) return { conversationId: input.conversationId, userClientMessageId: null }
315
+ await repo.appendMessage(
316
+ input.conversationId,
317
+ {
318
+ clientMessageId: userMessage.id,
319
+ role: 'user',
320
+ content: userMessage.content,
321
+ uiParts: userMessage.uiParts,
322
+ attachmentIds: input.attachmentIds,
323
+ files: userMessage.files?.map((file, index) => {
324
+ const id = file.id ?? input.attachmentIds?.[index]
325
+ const mimeType = file.mimeType ?? file.type
326
+ return {
327
+ ...(id ? { id } : {}),
328
+ ...(file.name ? { name: file.name } : {}),
329
+ ...(mimeType ? { mimeType } : {}),
330
+ ...(typeof file.size === 'number' ? { size: file.size } : {}),
331
+ }
332
+ }),
333
+ },
334
+ ctx,
335
+ )
336
+ return {
337
+ conversationId: input.conversationId,
338
+ userClientMessageId: userMessage.id ?? null,
339
+ }
340
+ }
341
+
342
+ function persistAssistantOnStreamCompletion(input: {
343
+ response: Response
344
+ container: Awaited<ReturnType<typeof createRequestContainer>>
345
+ tenantId: string | null | undefined
346
+ organizationId: string | null | undefined
347
+ userId: string
348
+ conversationId: string
349
+ userClientMessageId: string | null
350
+ }): Response {
351
+ if (!input.response.body || !input.tenantId) return input.response
352
+ const tenantId = input.tenantId
353
+ const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()
354
+ const writer = writable.getWriter()
355
+ const decoder = new TextDecoder()
356
+ const contentType = input.response.headers.get('content-type')
357
+
358
+ async function pump(): Promise<void> {
359
+ const reader = input.response.body!.getReader()
360
+ let raw = ''
361
+ try {
362
+ for (;;) {
363
+ const { value, done } = await reader.read()
364
+ if (done) break
365
+ if (!value) continue
366
+ raw += decoder.decode(value, { stream: true })
367
+ await writer.write(value)
368
+ }
369
+ raw += decoder.decode()
370
+ const assistant = extractAssistantSnapshot(raw, contentType)
371
+ if (assistant.content.trim() || assistant.uiParts.length > 0) {
372
+ const repo = createConversationStorage(input.container)
373
+ await repo.appendMessage(
374
+ input.conversationId,
375
+ {
376
+ clientMessageId: input.userClientMessageId
377
+ ? `${input.userClientMessageId}:assistant`
378
+ : undefined,
379
+ role: 'assistant',
380
+ content: assistant.content,
381
+ uiParts: assistant.uiParts,
382
+ },
383
+ {
384
+ tenantId,
385
+ organizationId: input.organizationId ?? null,
386
+ userId: input.userId,
387
+ },
388
+ )
389
+ }
390
+ } catch (error) {
391
+ console.error('[AI Chat Agent] Conversation persistence failure:', error)
392
+ } finally {
393
+ reader.releaseLock()
394
+ await writer.close().catch(() => undefined)
395
+ }
396
+ }
397
+
398
+ void pump()
399
+ return new Response(readable, {
400
+ status: input.response.status,
401
+ statusText: input.response.statusText,
402
+ headers: input.response.headers,
403
+ })
404
+ }
405
+
192
406
  export async function POST(req: NextRequest): Promise<Response> {
193
407
  const auth = await getAuthFromRequest(req)
194
408
  if (!auth) {
@@ -419,7 +633,27 @@ export async function POST(req: NextRequest): Promise<Response> {
419
633
  ? resolveLoopBudgetPreset(rawLoopBudget)
420
634
  : undefined
421
635
 
422
- return await runAiAgentText({
636
+ const effectiveConversationId = bodyResult.data.sessionId ?? bodyResult.data.conversationId ?? null
637
+ let persistedTurn:
638
+ | { conversationId: string; userClientMessageId: string | null }
639
+ | null = null
640
+ try {
641
+ persistedTurn = await persistChatTurnStart({
642
+ container,
643
+ tenantId: auth.tenantId ?? null,
644
+ organizationId: auth.orgId ?? null,
645
+ userId: auth.sub,
646
+ agentId,
647
+ conversationId: effectiveConversationId,
648
+ pageContext: bodyResult.data.pageContext,
649
+ messages: bodyResult.data.messages,
650
+ attachmentIds: bodyResult.data.attachmentIds,
651
+ })
652
+ } catch (error) {
653
+ console.error('[AI Chat Agent] Failed to persist user message:', error)
654
+ }
655
+
656
+ const response = await runAiAgentText({
423
657
  agentId,
424
658
  messages: bodyResult.data.messages as unknown as UIMessage[],
425
659
  attachmentIds: bodyResult.data.attachmentIds,
@@ -439,6 +673,16 @@ export async function POST(req: NextRequest): Promise<Response> {
439
673
  loop: loopFromPreset,
440
674
  emitLoopTrace: true,
441
675
  })
676
+ if (!persistedTurn) return response
677
+ return persistAssistantOnStreamCompletion({
678
+ response,
679
+ container,
680
+ tenantId: auth.tenantId ?? null,
681
+ organizationId: auth.orgId ?? null,
682
+ userId: auth.sub,
683
+ conversationId: persistedTurn.conversationId,
684
+ userClientMessageId: persistedTurn.userClientMessageId,
685
+ })
442
686
  } catch (error) {
443
687
  if (error instanceof AgentPolicyError) {
444
688
  return jsonError(statusForDenyCode(error.code), error.message, error.code)
@@ -0,0 +1,320 @@
1
+ import { NextResponse, type NextRequest } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
+ import {
8
+ aiChatConversationTranscriptQuerySchema,
9
+ aiChatConversationUpdateSchema,
10
+ } from '../../../../data/validators'
11
+ import { hasRequiredFeatures } from '../../../../lib/auth'
12
+ import {
13
+ createConversationStorage,
14
+ serializeAiChatConversation,
15
+ serializeAiChatMessage,
16
+ } from '../../../../lib/conversation-storage'
17
+
18
+ const REQUIRED_FEATURE = 'ai_assistant.view'
19
+ const MANAGE_CONVERSATIONS_FEATURE = 'ai_assistant.conversations.manage'
20
+
21
+ const conversationIdParamSchema = z.object({
22
+ conversationId: z
23
+ .string()
24
+ .trim()
25
+ .min(1, 'conversationId must be a non-empty string')
26
+ .max(128, 'conversationId exceeds the maximum length of 128 characters'),
27
+ })
28
+
29
+ export const openApi: OpenApiRouteDoc = {
30
+ tag: 'AI Assistant',
31
+ summary: 'Per-conversation AI chat operations',
32
+ methods: {
33
+ GET: {
34
+ operationId: 'aiAssistantGetConversation',
35
+ summary: 'Fetch a conversation summary and recent transcript.',
36
+ description:
37
+ 'Returns `{ conversation, messages, nextCursor }` for the supplied `conversationId`. ' +
38
+ 'View-only callers can load only their own conversations. Callers with ' +
39
+ '`ai_assistant.conversations.manage` can load conversations across users in the same ' +
40
+ 'tenant/organization. Messages are ordered ascending by `createdAt`. The `before` cursor ' +
41
+ 'returns the next older page when paging back through long transcripts.',
42
+ responses: [
43
+ {
44
+ status: 200,
45
+ description: 'Conversation transcript page for the authenticated owner.',
46
+ mediaType: 'application/json',
47
+ },
48
+ ],
49
+ errors: [
50
+ { status: 400, description: 'Invalid path or query parameters.' },
51
+ { status: 401, description: 'Unauthenticated caller.' },
52
+ { status: 403, description: 'Caller lacks the `ai_assistant.view` feature.' },
53
+ { status: 404, description: 'No conversation accessible to the caller.' },
54
+ ],
55
+ },
56
+ PATCH: {
57
+ operationId: 'aiAssistantUpdateConversation',
58
+ summary: 'Update an existing conversation.',
59
+ description:
60
+ 'Accepts a partial body containing any of `title`, `status`, `pageContext`. Setting ' +
61
+ '`status` to `closed` archives the conversation while keeping its transcript intact. ' +
62
+ 'View-only callers can update only their own conversations; conversation managers can ' +
63
+ 'update conversations in the same tenant/organization.',
64
+ responses: [
65
+ {
66
+ status: 200,
67
+ description: 'Updated conversation summary.',
68
+ mediaType: 'application/json',
69
+ },
70
+ ],
71
+ errors: [
72
+ { status: 400, description: 'Invalid request body.' },
73
+ { status: 401, description: 'Unauthenticated caller.' },
74
+ { status: 403, description: 'Caller lacks the `ai_assistant.view` feature.' },
75
+ { status: 404, description: 'No conversation accessible to the caller.' },
76
+ ],
77
+ },
78
+ DELETE: {
79
+ operationId: 'aiAssistantDeleteConversation',
80
+ summary: 'Soft-delete a conversation and its messages.',
81
+ description:
82
+ 'View-only callers can delete only their own conversations. Callers with ' +
83
+ '`ai_assistant.conversations.manage` can delete conversations in the same tenant/organization. ' +
84
+ 'Marks the conversation row and every undeleted message row with a `deleted_at` timestamp ' +
85
+ 'in one transaction. The transcript remains in the database for audit/restore until a future ' +
86
+ 'retention worker hard-deletes it.',
87
+ responses: [
88
+ {
89
+ status: 200,
90
+ description: 'Soft-delete acknowledgment.',
91
+ mediaType: 'application/json',
92
+ },
93
+ ],
94
+ errors: [
95
+ { status: 401, description: 'Unauthenticated caller.' },
96
+ { status: 403, description: 'Caller lacks the `ai_assistant.view` feature.' },
97
+ { status: 404, description: 'No conversation accessible to the caller.' },
98
+ ],
99
+ },
100
+ },
101
+ }
102
+
103
+ export const metadata = {
104
+ GET: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
105
+ PATCH: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
106
+ DELETE: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
107
+ }
108
+
109
+ interface RouteContext {
110
+ params: Promise<{ conversationId: string }>
111
+ }
112
+
113
+ function jsonError(
114
+ status: number,
115
+ message: string,
116
+ code: string,
117
+ extra?: Record<string, unknown>,
118
+ ): NextResponse {
119
+ return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })
120
+ }
121
+
122
+ async function resolveCallerContext(req: NextRequest, context: RouteContext): Promise<
123
+ | { kind: 'unauthorized' }
124
+ | { kind: 'forbidden' }
125
+ | { kind: 'missing-tenant' }
126
+ | { kind: 'invalid-id'; issues: unknown }
127
+ | {
128
+ kind: 'ok'
129
+ tenantId: string
130
+ organizationId: string | null
131
+ userId: string
132
+ conversationId: string
133
+ canManageConversations: boolean
134
+ }
135
+ > {
136
+ const auth = await getAuthFromRequest(req)
137
+ if (!auth) return { kind: 'unauthorized' }
138
+ const rawParams = await context.params
139
+ const parseResult = conversationIdParamSchema.safeParse(rawParams)
140
+ if (!parseResult.success) {
141
+ return { kind: 'invalid-id', issues: parseResult.error.issues }
142
+ }
143
+ const container = await createRequestContainer()
144
+ const rbacService = container.resolve<RbacService>('rbacService')
145
+ const acl = await rbacService.loadAcl(auth.sub, {
146
+ tenantId: auth.tenantId,
147
+ organizationId: auth.orgId,
148
+ })
149
+ if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {
150
+ return { kind: 'forbidden' }
151
+ }
152
+ const canManageConversations = hasRequiredFeatures(
153
+ [MANAGE_CONVERSATIONS_FEATURE],
154
+ acl.features,
155
+ acl.isSuperAdmin,
156
+ rbacService,
157
+ )
158
+ if (!auth.tenantId) return { kind: 'missing-tenant' }
159
+ return {
160
+ kind: 'ok',
161
+ tenantId: auth.tenantId,
162
+ organizationId: auth.orgId ?? null,
163
+ userId: auth.sub,
164
+ conversationId: parseResult.data.conversationId,
165
+ canManageConversations,
166
+ }
167
+ }
168
+
169
+ export async function GET(req: NextRequest, context: RouteContext): Promise<Response> {
170
+ const callerCtx = await resolveCallerContext(req, context)
171
+ if (callerCtx.kind === 'unauthorized') return jsonError(401, 'Unauthorized', 'unauthenticated')
172
+ if (callerCtx.kind === 'invalid-id') {
173
+ return jsonError(400, 'Invalid conversation id.', 'validation_error', {
174
+ issues: callerCtx.issues,
175
+ })
176
+ }
177
+ if (callerCtx.kind === 'forbidden') {
178
+ return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
179
+ }
180
+ if (callerCtx.kind === 'missing-tenant') {
181
+ return jsonError(404, 'Conversation not found.', 'conversation_not_found')
182
+ }
183
+
184
+ const url = new URL(req.url)
185
+ const queryResult = aiChatConversationTranscriptQuerySchema.safeParse({
186
+ limit: url.searchParams.get('limit') ?? undefined,
187
+ before: url.searchParams.get('before') ?? undefined,
188
+ })
189
+ if (!queryResult.success) {
190
+ return jsonError(400, 'Invalid query parameters.', 'validation_error', {
191
+ issues: queryResult.error.issues,
192
+ })
193
+ }
194
+
195
+ try {
196
+ const container = await createRequestContainer()
197
+ const repo = createConversationStorage(container)
198
+ const transcript = await repo.getTranscript(
199
+ callerCtx.conversationId,
200
+ {
201
+ tenantId: callerCtx.tenantId,
202
+ organizationId: callerCtx.organizationId,
203
+ userId: callerCtx.userId,
204
+ canManageConversations: callerCtx.canManageConversations,
205
+ },
206
+ {
207
+ limit: queryResult.data.limit,
208
+ before: queryResult.data.before ?? null,
209
+ },
210
+ )
211
+ if (!transcript) {
212
+ return jsonError(404, 'Conversation not found.', 'conversation_not_found')
213
+ }
214
+ return NextResponse.json({
215
+ conversation: serializeAiChatConversation(transcript.conversation),
216
+ messages: transcript.messages.map(serializeAiChatMessage),
217
+ nextCursor: transcript.nextCursor,
218
+ })
219
+ } catch (error) {
220
+ console.error('[AI Conversation GET] Failure:', error)
221
+ return jsonError(
222
+ 500,
223
+ error instanceof Error ? error.message : 'Failed to load conversation.',
224
+ 'internal_error',
225
+ )
226
+ }
227
+ }
228
+
229
+ export async function PATCH(req: NextRequest, context: RouteContext): Promise<Response> {
230
+ const callerCtx = await resolveCallerContext(req, context)
231
+ if (callerCtx.kind === 'unauthorized') return jsonError(401, 'Unauthorized', 'unauthenticated')
232
+ if (callerCtx.kind === 'invalid-id') {
233
+ return jsonError(400, 'Invalid conversation id.', 'validation_error', {
234
+ issues: callerCtx.issues,
235
+ })
236
+ }
237
+ if (callerCtx.kind === 'forbidden') {
238
+ return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
239
+ }
240
+ if (callerCtx.kind === 'missing-tenant') {
241
+ return jsonError(404, 'Conversation not found.', 'conversation_not_found')
242
+ }
243
+
244
+ let rawBody: unknown
245
+ try {
246
+ rawBody = await req.json()
247
+ } catch {
248
+ return jsonError(400, 'Request body must be valid JSON.', 'validation_error')
249
+ }
250
+ const parseResult = aiChatConversationUpdateSchema.safeParse(rawBody)
251
+ if (!parseResult.success) {
252
+ return jsonError(400, 'Invalid conversation patch.', 'validation_error', {
253
+ issues: parseResult.error.issues,
254
+ })
255
+ }
256
+
257
+ try {
258
+ const container = await createRequestContainer()
259
+ const repo = createConversationStorage(container)
260
+ const row = await repo.update(
261
+ callerCtx.conversationId,
262
+ parseResult.data,
263
+ {
264
+ tenantId: callerCtx.tenantId,
265
+ organizationId: callerCtx.organizationId,
266
+ userId: callerCtx.userId,
267
+ canManageConversations: callerCtx.canManageConversations,
268
+ },
269
+ )
270
+ return NextResponse.json(serializeAiChatConversation(row))
271
+ } catch (error) {
272
+ if (error instanceof Error && error.name === 'AiChatConversationAccessError') {
273
+ return jsonError(404, 'Conversation not found.', 'conversation_not_found')
274
+ }
275
+ console.error('[AI Conversation PATCH] Failure:', error)
276
+ return jsonError(
277
+ 500,
278
+ error instanceof Error ? error.message : 'Failed to update conversation.',
279
+ 'internal_error',
280
+ )
281
+ }
282
+ }
283
+
284
+ export async function DELETE(req: NextRequest, context: RouteContext): Promise<Response> {
285
+ const callerCtx = await resolveCallerContext(req, context)
286
+ if (callerCtx.kind === 'unauthorized') return jsonError(401, 'Unauthorized', 'unauthenticated')
287
+ if (callerCtx.kind === 'invalid-id') {
288
+ return jsonError(400, 'Invalid conversation id.', 'validation_error', {
289
+ issues: callerCtx.issues,
290
+ })
291
+ }
292
+ if (callerCtx.kind === 'forbidden') {
293
+ return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
294
+ }
295
+ if (callerCtx.kind === 'missing-tenant') {
296
+ return jsonError(404, 'Conversation not found.', 'conversation_not_found')
297
+ }
298
+
299
+ try {
300
+ const container = await createRequestContainer()
301
+ const repo = createConversationStorage(container)
302
+ await repo.softDelete(callerCtx.conversationId, {
303
+ tenantId: callerCtx.tenantId,
304
+ organizationId: callerCtx.organizationId,
305
+ userId: callerCtx.userId,
306
+ canManageConversations: callerCtx.canManageConversations,
307
+ })
308
+ return NextResponse.json({ ok: true })
309
+ } catch (error) {
310
+ if (error instanceof Error && error.name === 'AiChatConversationAccessError') {
311
+ return jsonError(404, 'Conversation not found.', 'conversation_not_found')
312
+ }
313
+ console.error('[AI Conversation DELETE] Failure:', error)
314
+ return jsonError(
315
+ 500,
316
+ error instanceof Error ? error.message : 'Failed to delete conversation.',
317
+ 'internal_error',
318
+ )
319
+ }
320
+ }
@@ -0,0 +1,93 @@
1
+ const authMock = jest.fn()
2
+ const loadAclMock = jest.fn()
3
+ const hasAllFeaturesMock = jest.fn()
4
+ const createRequestContainerMock = jest.fn()
5
+ const listConversationsMock = jest.fn()
6
+
7
+ jest.mock('@open-mercato/shared/lib/auth/server', () => ({
8
+ getAuthFromRequest: (...args: unknown[]) => authMock(...args),
9
+ }))
10
+
11
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
12
+ createRequestContainer: (...args: unknown[]) => createRequestContainerMock(...args),
13
+ }))
14
+
15
+ jest.mock('../../../../lib/conversation-storage', () => ({
16
+ createConversationStorage: jest.fn(() => ({
17
+ list: (...args: unknown[]) => listConversationsMock(...args),
18
+ })),
19
+ serializeAiChatConversation: (row: unknown) => row,
20
+ }))
21
+
22
+ import { GET } from '../route'
23
+
24
+ function buildRequest(query = ''): Request {
25
+ return new Request(`http://localhost/api/ai_assistant/ai/conversations${query}`, {
26
+ method: 'GET',
27
+ })
28
+ }
29
+
30
+ describe('GET /api/ai/conversations', () => {
31
+ beforeEach(() => {
32
+ jest.clearAllMocks()
33
+ authMock.mockResolvedValue({
34
+ sub: 'user-1',
35
+ tenantId: 'tenant-1',
36
+ orgId: 'org-1',
37
+ })
38
+ hasAllFeaturesMock.mockImplementation((required: string[], features: string[]) =>
39
+ required.every((feature) => features.includes(feature)),
40
+ )
41
+ createRequestContainerMock.mockResolvedValue({
42
+ resolve: (name: string) => {
43
+ if (name === 'rbacService') {
44
+ return {
45
+ loadAcl: (...args: unknown[]) => loadAclMock(...args),
46
+ hasAllFeatures: (...args: unknown[]) => hasAllFeaturesMock(...args),
47
+ }
48
+ }
49
+ return null
50
+ },
51
+ })
52
+ loadAclMock.mockResolvedValue({
53
+ features: ['ai_assistant.view'],
54
+ isSuperAdmin: false,
55
+ })
56
+ listConversationsMock.mockResolvedValue({ items: [], nextCursor: null })
57
+ })
58
+
59
+ it('passes owner-only scope to storage for view-only callers', async () => {
60
+ const response = await GET(buildRequest('?agent=catalog.assistant') as any)
61
+ expect(response.status).toBe(200)
62
+ expect(listConversationsMock).toHaveBeenCalledWith(
63
+ {
64
+ tenantId: 'tenant-1',
65
+ organizationId: 'org-1',
66
+ userId: 'user-1',
67
+ canManageConversations: false,
68
+ },
69
+ expect.objectContaining({
70
+ agentId: 'catalog.assistant',
71
+ }),
72
+ )
73
+ })
74
+
75
+ it('passes tenant-scoped manage scope when the caller has conversation management', async () => {
76
+ loadAclMock.mockResolvedValueOnce({
77
+ features: ['ai_assistant.view', 'ai_assistant.conversations.manage'],
78
+ isSuperAdmin: false,
79
+ })
80
+
81
+ const response = await GET(buildRequest() as any)
82
+ expect(response.status).toBe(200)
83
+ expect(listConversationsMock).toHaveBeenCalledWith(
84
+ expect.objectContaining({
85
+ tenantId: 'tenant-1',
86
+ organizationId: 'org-1',
87
+ userId: 'user-1',
88
+ canManageConversations: true,
89
+ }),
90
+ expect.any(Object),
91
+ )
92
+ })
93
+ })