@open-mercato/ai-assistant 0.6.3-develop.3894.1.352abf4240 → 0.6.3

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 (80) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-06-deep-link.spec.js +87 -0
  3. package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-06-deep-link.spec.js.map +7 -0
  4. package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-07-owner-label.spec.js +119 -0
  5. package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-07-owner-label.spec.js.map +7 -0
  6. package/dist/modules/ai_assistant/acl.js +1 -0
  7. package/dist/modules/ai_assistant/acl.js.map +2 -2
  8. package/dist/modules/ai_assistant/api/ai/chat/route.js +3 -0
  9. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  10. package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/[userId]/route.js +128 -0
  11. package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/[userId]/route.js.map +7 -0
  12. package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/route.js +271 -0
  13. package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/route.js.map +7 -0
  14. package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/route.js +9 -1
  15. package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/route.js.map +2 -2
  16. package/dist/modules/ai_assistant/api/ai/conversations/route.js +4 -1
  17. package/dist/modules/ai_assistant/api/ai/conversations/route.js.map +2 -2
  18. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +5 -1
  19. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
  20. package/dist/modules/ai_assistant/components/ConversationShareButton.js +5 -0
  21. package/dist/modules/ai_assistant/components/ConversationShareButton.js.map +7 -0
  22. package/dist/modules/ai_assistant/components/ConversationShareDialog.js +5 -0
  23. package/dist/modules/ai_assistant/components/ConversationShareDialog.js.map +7 -0
  24. package/dist/modules/ai_assistant/data/entities.js +3 -0
  25. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  26. package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js +235 -5
  27. package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js.map +2 -2
  28. package/dist/modules/ai_assistant/events.js +14 -0
  29. package/dist/modules/ai_assistant/events.js.map +2 -2
  30. package/dist/modules/ai_assistant/i18n/de.json +17 -0
  31. package/dist/modules/ai_assistant/i18n/en.json +17 -0
  32. package/dist/modules/ai_assistant/i18n/es.json +17 -0
  33. package/dist/modules/ai_assistant/i18n/pl.json +17 -0
  34. package/dist/modules/ai_assistant/lib/conversation-storage.js +12 -3
  35. package/dist/modules/ai_assistant/lib/conversation-storage.js.map +2 -2
  36. package/dist/modules/ai_assistant/migrations/Migration20260522120000_ai_assistant.js +15 -0
  37. package/dist/modules/ai_assistant/migrations/Migration20260522120000_ai_assistant.js.map +7 -0
  38. package/dist/modules/ai_assistant/notifications.client.js +30 -0
  39. package/dist/modules/ai_assistant/notifications.client.js.map +7 -0
  40. package/dist/modules/ai_assistant/notifications.js +27 -0
  41. package/dist/modules/ai_assistant/notifications.js.map +7 -0
  42. package/dist/modules/ai_assistant/setup.js +2 -1
  43. package/dist/modules/ai_assistant/setup.js.map +2 -2
  44. package/dist/modules/ai_assistant/subscribers/conversation-shared-notify.js +59 -0
  45. package/dist/modules/ai_assistant/subscribers/conversation-shared-notify.js.map +7 -0
  46. package/dist/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.js +123 -0
  47. package/dist/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.js.map +7 -0
  48. package/generated/entities/ai_chat_conversation_participant/index.ts +1 -0
  49. package/generated/entity-fields-registry.ts +1 -0
  50. package/package.json +7 -8
  51. package/src/modules/ai_assistant/__integration__/TC-AI-sharing-06-deep-link.spec.ts +117 -0
  52. package/src/modules/ai_assistant/__integration__/TC-AI-sharing-07-owner-label.spec.ts +159 -0
  53. package/src/modules/ai_assistant/__tests__/integration/ai-chat-sharing.test.ts +406 -0
  54. package/src/modules/ai_assistant/acl.ts +1 -0
  55. package/src/modules/ai_assistant/api/ai/chat/route.ts +3 -0
  56. package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/[userId]/route.ts +149 -0
  57. package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/route.ts +314 -0
  58. package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/route.ts +9 -1
  59. package/src/modules/ai_assistant/api/ai/conversations/route.ts +4 -1
  60. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +4 -0
  61. package/src/modules/ai_assistant/components/ConversationShareButton.tsx +1 -0
  62. package/src/modules/ai_assistant/components/ConversationShareDialog.tsx +1 -0
  63. package/src/modules/ai_assistant/data/entities.ts +4 -0
  64. package/src/modules/ai_assistant/data/repositories/AiChatConversationRepository.ts +270 -7
  65. package/src/modules/ai_assistant/data/repositories/__tests__/AiChatConversationRepository.test.ts +297 -3
  66. package/src/modules/ai_assistant/events.ts +31 -0
  67. package/src/modules/ai_assistant/i18n/__tests__/conversation-share-translations.test.ts +59 -0
  68. package/src/modules/ai_assistant/i18n/de.json +17 -0
  69. package/src/modules/ai_assistant/i18n/en.json +17 -0
  70. package/src/modules/ai_assistant/i18n/es.json +17 -0
  71. package/src/modules/ai_assistant/i18n/pl.json +17 -0
  72. package/src/modules/ai_assistant/lib/conversation-storage.ts +22 -1
  73. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +25 -0
  74. package/src/modules/ai_assistant/migrations/Migration20260522120000_ai_assistant.ts +15 -0
  75. package/src/modules/ai_assistant/notifications.client.ts +29 -0
  76. package/src/modules/ai_assistant/notifications.ts +25 -0
  77. package/src/modules/ai_assistant/setup.ts +2 -1
  78. package/src/modules/ai_assistant/subscribers/__tests__/conversation-shared-notify.test.ts +116 -0
  79. package/src/modules/ai_assistant/subscribers/conversation-shared-notify.ts +78 -0
  80. package/src/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.tsx +121 -0
@@ -0,0 +1,149 @@
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 { hasRequiredFeatures } from '../../../../../../lib/auth'
8
+ import {
9
+ AiChatConversationAccessError,
10
+ AiChatParticipantNotFoundError,
11
+ createConversationStorage,
12
+ } from '../../../../../../lib/conversation-storage'
13
+ import { emitAiAssistantEvent } from '../../../../../../events'
14
+
15
+ const REQUIRED_FEATURE = 'ai_assistant.view'
16
+ const MANAGE_CONVERSATIONS_FEATURE = 'ai_assistant.conversations.manage'
17
+ const SHARE_CONVERSATIONS_FEATURE = 'ai_assistant.conversations.share'
18
+
19
+ const participantParamsSchema = z.object({
20
+ conversationId: z
21
+ .string()
22
+ .trim()
23
+ .min(1, 'conversationId must be a non-empty string')
24
+ .max(128, 'conversationId exceeds the maximum length of 128 characters'),
25
+ userId: z.string().uuid('userId must be a valid UUID'),
26
+ })
27
+
28
+ export const openApi: OpenApiRouteDoc = {
29
+ tag: 'AI Assistant',
30
+ summary: 'Revoke a conversation participant',
31
+ methods: {
32
+ DELETE: {
33
+ operationId: 'aiAssistantRevokeConversationParticipant',
34
+ summary: 'Revoke a participant from a conversation (soft-delete).',
35
+ description:
36
+ 'Soft-deletes the participant row. If no active non-owner participants remain, ' +
37
+ 'the conversation visibility is reset to "private". ' +
38
+ 'Only the conversation owner or a manager may revoke participants.',
39
+ responses: [
40
+ {
41
+ status: 204,
42
+ description: 'Participant revoked.',
43
+ },
44
+ ],
45
+ errors: [
46
+ { status: 400, description: 'Invalid path parameters.' },
47
+ { status: 401, description: 'Unauthenticated caller.' },
48
+ { status: 403, description: 'Caller lacks required features or is not the owner.' },
49
+ { status: 404, description: 'Conversation not found.' },
50
+ ],
51
+ },
52
+ },
53
+ }
54
+
55
+ export const metadata = {
56
+ DELETE: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
57
+ }
58
+
59
+ interface RouteContext {
60
+ params: Promise<{ conversationId: string; userId: string }>
61
+ }
62
+
63
+ function jsonError(
64
+ status: number,
65
+ message: string,
66
+ code: string,
67
+ extra?: Record<string, unknown>,
68
+ ): NextResponse {
69
+ return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })
70
+ }
71
+
72
+ export async function DELETE(req: NextRequest, context: RouteContext): Promise<Response> {
73
+ const auth = await getAuthFromRequest(req)
74
+ if (!auth) return jsonError(401, 'Unauthorized', 'unauthenticated')
75
+ const rawParams = await context.params
76
+ const parseResult = participantParamsSchema.safeParse(rawParams)
77
+ if (!parseResult.success) {
78
+ return jsonError(400, 'Invalid path parameters.', 'validation_error', {
79
+ issues: parseResult.error.issues,
80
+ })
81
+ }
82
+ if (!auth.tenantId) return jsonError(404, 'Conversation not found.', 'conversation_not_found')
83
+
84
+ const container = await createRequestContainer()
85
+ const rbacService = container.resolve<RbacService>('rbacService')
86
+ const acl = await rbacService.loadAcl(auth.sub, {
87
+ tenantId: auth.tenantId,
88
+ organizationId: auth.orgId,
89
+ })
90
+ if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {
91
+ return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
92
+ }
93
+ const canShare = hasRequiredFeatures(
94
+ [SHARE_CONVERSATIONS_FEATURE],
95
+ acl.features,
96
+ acl.isSuperAdmin,
97
+ rbacService,
98
+ )
99
+ if (!canShare) {
100
+ return jsonError(
101
+ 403,
102
+ `Caller lacks required feature "${SHARE_CONVERSATIONS_FEATURE}".`,
103
+ 'forbidden',
104
+ )
105
+ }
106
+
107
+ try {
108
+ const repo = createConversationStorage(container)
109
+ await repo.revokeParticipant(
110
+ parseResult.data.conversationId,
111
+ parseResult.data.userId,
112
+ {
113
+ tenantId: auth.tenantId,
114
+ organizationId: auth.orgId ?? null,
115
+ userId: auth.sub,
116
+ canManageConversations: hasRequiredFeatures(
117
+ [MANAGE_CONVERSATIONS_FEATURE],
118
+ acl.features,
119
+ acl.isSuperAdmin,
120
+ rbacService,
121
+ ),
122
+ },
123
+ )
124
+ try {
125
+ await emitAiAssistantEvent(
126
+ 'ai_assistant.conversation.unshared',
127
+ {
128
+ conversationId: parseResult.data.conversationId,
129
+ tenantId: auth.tenantId,
130
+ organizationId: auth.orgId ?? null,
131
+ ownerUserId: auth.sub,
132
+ participantUserId: parseResult.data.userId,
133
+ },
134
+ { persistent: false },
135
+ )
136
+ } catch {
137
+ // non-fatal
138
+ }
139
+ return new NextResponse(null, { status: 204 })
140
+ } catch (err) {
141
+ if (err instanceof AiChatParticipantNotFoundError) {
142
+ return jsonError(404, err.message || 'Participant not found or already revoked.', 'participant_not_found')
143
+ }
144
+ if (err instanceof AiChatConversationAccessError) {
145
+ return jsonError(403, err.message || 'Access denied.', 'forbidden')
146
+ }
147
+ return jsonError(500, 'Internal server error.', 'internal_error')
148
+ }
149
+ }
@@ -0,0 +1,314 @@
1
+ import { NextResponse, type NextRequest } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import type { FilterQuery } from '@mikro-orm/core'
5
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
6
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
7
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
8
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
9
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
10
+ import { User } from '@open-mercato/core/modules/auth/data/entities'
11
+ import { hasRequiredFeatures } from '../../../../../lib/auth'
12
+ import {
13
+ createConversationStorage,
14
+ AiChatConversationAccessError,
15
+ AiChatConversationDuplicateParticipantError,
16
+ } from '../../../../../lib/conversation-storage'
17
+ import { emitAiAssistantEvent } from '../../../../../events'
18
+
19
+ const REQUIRED_FEATURE = 'ai_assistant.view'
20
+ const MANAGE_CONVERSATIONS_FEATURE = 'ai_assistant.conversations.manage'
21
+ const SHARE_CONVERSATIONS_FEATURE = 'ai_assistant.conversations.share'
22
+
23
+ const conversationIdParamSchema = z.object({
24
+ conversationId: z
25
+ .string()
26
+ .trim()
27
+ .min(1, 'conversationId must be a non-empty string')
28
+ .max(128, 'conversationId exceeds the maximum length of 128 characters'),
29
+ })
30
+
31
+ const addParticipantBodySchema = z.object({
32
+ userId: z.string().uuid('userId must be a valid UUID'),
33
+ role: z.enum(['viewer']).default('viewer'),
34
+ })
35
+
36
+ export const openApi: OpenApiRouteDoc = {
37
+ tag: 'AI Assistant',
38
+ summary: 'Manage conversation participants',
39
+ methods: {
40
+ GET: {
41
+ operationId: 'aiAssistantListConversationParticipants',
42
+ summary: 'List active participants of a conversation.',
43
+ description:
44
+ 'Returns the list of active (non-revoked) participants for the conversation. ' +
45
+ 'Only the conversation owner or a caller with `ai_assistant.conversations.manage` can call this endpoint.',
46
+ responses: [
47
+ {
48
+ status: 200,
49
+ description: 'List of active participants.',
50
+ mediaType: 'application/json',
51
+ },
52
+ ],
53
+ errors: [
54
+ { status: 401, description: 'Unauthenticated caller.' },
55
+ { status: 403, description: 'Caller lacks required features.' },
56
+ { status: 404, description: 'Conversation not found or not accessible.' },
57
+ ],
58
+ },
59
+ POST: {
60
+ operationId: 'aiAssistantAddConversationParticipant',
61
+ summary: 'Add a participant to a conversation.',
62
+ description:
63
+ 'Grants a named user read access to the conversation. Requires `ai_assistant.conversations.share`. ' +
64
+ 'Only the conversation owner may add participants. If the user was previously revoked, the soft-deleted row is restored.',
65
+ responses: [
66
+ {
67
+ status: 201,
68
+ description: 'Participant added; conversation visibility updated to "shared".',
69
+ mediaType: 'application/json',
70
+ },
71
+ ],
72
+ errors: [
73
+ { status: 400, description: 'Invalid request body.' },
74
+ { status: 401, description: 'Unauthenticated caller.' },
75
+ { status: 403, description: 'Caller lacks required feature or is not the owner.' },
76
+ { status: 404, description: 'Conversation not found.' },
77
+ ],
78
+ },
79
+ },
80
+ }
81
+
82
+ export const metadata = {
83
+ GET: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
84
+ POST: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
85
+ }
86
+
87
+ interface RouteContext {
88
+ params: Promise<{ conversationId: string }>
89
+ }
90
+
91
+ function jsonError(
92
+ status: number,
93
+ message: string,
94
+ code: string,
95
+ extra?: Record<string, unknown>,
96
+ ): NextResponse {
97
+ return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })
98
+ }
99
+
100
+ async function resolveCallerContext(req: NextRequest, context: RouteContext): Promise<
101
+ | { kind: 'unauthorized' }
102
+ | { kind: 'forbidden' }
103
+ | { kind: 'missing-tenant' }
104
+ | { kind: 'invalid-id'; issues: unknown }
105
+ | {
106
+ kind: 'ok'
107
+ tenantId: string
108
+ organizationId: string | null
109
+ userId: string
110
+ conversationId: string
111
+ canManageConversations: boolean
112
+ canShare: boolean
113
+ }
114
+ > {
115
+ const auth = await getAuthFromRequest(req)
116
+ if (!auth) return { kind: 'unauthorized' }
117
+ const rawParams = await context.params
118
+ const parseResult = conversationIdParamSchema.safeParse(rawParams)
119
+ if (!parseResult.success) {
120
+ return { kind: 'invalid-id', issues: parseResult.error.issues }
121
+ }
122
+ const container = await createRequestContainer()
123
+ const rbacService = container.resolve<RbacService>('rbacService')
124
+ const acl = await rbacService.loadAcl(auth.sub, {
125
+ tenantId: auth.tenantId,
126
+ organizationId: auth.orgId,
127
+ })
128
+ if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {
129
+ return { kind: 'forbidden' }
130
+ }
131
+ if (!auth.tenantId) return { kind: 'missing-tenant' }
132
+ return {
133
+ kind: 'ok',
134
+ tenantId: auth.tenantId,
135
+ organizationId: auth.orgId ?? null,
136
+ userId: auth.sub,
137
+ conversationId: parseResult.data.conversationId,
138
+ canManageConversations: hasRequiredFeatures(
139
+ [MANAGE_CONVERSATIONS_FEATURE],
140
+ acl.features,
141
+ acl.isSuperAdmin,
142
+ rbacService,
143
+ ),
144
+ canShare: hasRequiredFeatures(
145
+ [SHARE_CONVERSATIONS_FEATURE],
146
+ acl.features,
147
+ acl.isSuperAdmin,
148
+ rbacService,
149
+ ),
150
+ }
151
+ }
152
+
153
+ export async function GET(req: NextRequest, context: RouteContext): Promise<Response> {
154
+ const callerCtx = await resolveCallerContext(req, context)
155
+ if (callerCtx.kind === 'unauthorized') return jsonError(401, 'Unauthorized', 'unauthenticated')
156
+ if (callerCtx.kind === 'invalid-id') {
157
+ return jsonError(400, 'Invalid conversation id.', 'validation_error', {
158
+ issues: callerCtx.issues,
159
+ })
160
+ }
161
+ if (callerCtx.kind === 'forbidden') {
162
+ return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
163
+ }
164
+ if (callerCtx.kind === 'missing-tenant') {
165
+ return jsonError(404, 'Conversation not found.', 'conversation_not_found')
166
+ }
167
+
168
+ try {
169
+ const container = await createRequestContainer()
170
+ const repo = createConversationStorage(container)
171
+ const repoCtx = {
172
+ tenantId: callerCtx.tenantId,
173
+ organizationId: callerCtx.organizationId,
174
+ userId: callerCtx.userId,
175
+ canManageConversations: callerCtx.canManageConversations,
176
+ }
177
+ const conversation = await repo.getById(callerCtx.conversationId, repoCtx)
178
+ if (!conversation) {
179
+ return jsonError(404, 'Conversation not found.', 'conversation_not_found')
180
+ }
181
+ const participants = await repo.listParticipants(callerCtx.conversationId, repoCtx)
182
+ return NextResponse.json({
183
+ ownerUserId: conversation.ownerUserId,
184
+ participants: participants.map((p) => ({
185
+ userId: p.userId,
186
+ role: p.role,
187
+ lastReadAt: p.lastReadAt ? p.lastReadAt.toISOString() : null,
188
+ addedAt: p.createdAt.toISOString(),
189
+ })),
190
+ })
191
+ } catch (err) {
192
+ if (err instanceof AiChatConversationAccessError) {
193
+ return jsonError(403, 'Access denied.', 'forbidden')
194
+ }
195
+ return jsonError(500, 'Internal server error.', 'internal_error')
196
+ }
197
+ }
198
+
199
+ export async function POST(req: NextRequest, context: RouteContext): Promise<Response> {
200
+ const callerCtx = await resolveCallerContext(req, context)
201
+ if (callerCtx.kind === 'unauthorized') return jsonError(401, 'Unauthorized', 'unauthenticated')
202
+ if (callerCtx.kind === 'invalid-id') {
203
+ return jsonError(400, 'Invalid conversation id.', 'validation_error', {
204
+ issues: callerCtx.issues,
205
+ })
206
+ }
207
+ if (callerCtx.kind === 'forbidden') {
208
+ return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
209
+ }
210
+ if (callerCtx.kind === 'missing-tenant') {
211
+ return jsonError(404, 'Conversation not found.', 'conversation_not_found')
212
+ }
213
+ if (!callerCtx.canShare) {
214
+ return jsonError(
215
+ 403,
216
+ `Caller lacks required feature "${SHARE_CONVERSATIONS_FEATURE}".`,
217
+ 'forbidden',
218
+ )
219
+ }
220
+
221
+ let body: unknown
222
+ try {
223
+ body = await req.json()
224
+ } catch {
225
+ return jsonError(400, 'Invalid JSON body.', 'invalid_body')
226
+ }
227
+ const parseResult = addParticipantBodySchema.safeParse(body)
228
+ if (!parseResult.success) {
229
+ return jsonError(400, 'Invalid request body.', 'validation_error', {
230
+ issues: parseResult.error.issues,
231
+ })
232
+ }
233
+
234
+ const targetUserId = parseResult.data.userId
235
+ if (targetUserId === callerCtx.userId) {
236
+ return jsonError(400, 'Cannot share a conversation with yourself.', 'self_share_not_allowed')
237
+ }
238
+
239
+ try {
240
+ const container = await createRequestContainer()
241
+ const em = container.resolve<EntityManager>('em')
242
+ const targetUserFilter: FilterQuery<User> = {
243
+ id: targetUserId,
244
+ tenantId: callerCtx.tenantId,
245
+ deletedAt: null,
246
+ ...(callerCtx.organizationId ? { organizationId: callerCtx.organizationId } : {}),
247
+ }
248
+ const targetUser = await findOneWithDecryption<User>(
249
+ em,
250
+ User,
251
+ targetUserFilter,
252
+ {},
253
+ { tenantId: callerCtx.tenantId, organizationId: callerCtx.organizationId },
254
+ )
255
+ if (!targetUser) {
256
+ return jsonError(
257
+ 400,
258
+ 'Target user must be a staff user in the same tenant and organization.',
259
+ 'user_not_found',
260
+ )
261
+ }
262
+
263
+ const repo = createConversationStorage(container)
264
+ const participant = await repo.addParticipant(
265
+ callerCtx.conversationId,
266
+ targetUserId,
267
+ parseResult.data.role,
268
+ {
269
+ tenantId: callerCtx.tenantId,
270
+ organizationId: callerCtx.organizationId,
271
+ userId: callerCtx.userId,
272
+ canManageConversations: callerCtx.canManageConversations,
273
+ },
274
+ )
275
+ try {
276
+ await emitAiAssistantEvent(
277
+ 'ai_assistant.conversation.shared',
278
+ {
279
+ conversationId: callerCtx.conversationId,
280
+ tenantId: callerCtx.tenantId,
281
+ organizationId: callerCtx.organizationId,
282
+ ownerUserId: callerCtx.userId,
283
+ participantUserId: participant.userId,
284
+ role: participant.role,
285
+ },
286
+ { persistent: false },
287
+ )
288
+ } catch {
289
+ // non-fatal
290
+ }
291
+ return NextResponse.json(
292
+ {
293
+ participant: {
294
+ userId: participant.userId,
295
+ role: participant.role,
296
+ lastReadAt: participant.lastReadAt ? participant.lastReadAt.toISOString() : null,
297
+ addedAt: participant.createdAt.toISOString(),
298
+ },
299
+ },
300
+ { status: 201 },
301
+ )
302
+ } catch (err) {
303
+ if (err instanceof AiChatConversationDuplicateParticipantError) {
304
+ return jsonError(409, err.message, 'duplicate_participant')
305
+ }
306
+ if (err instanceof AiChatConversationAccessError) {
307
+ return jsonError(404, 'Conversation not found.', 'conversation_not_found')
308
+ }
309
+ if (err instanceof Error && err.message.toLowerCase().includes('owner')) {
310
+ return jsonError(403, err.message, 'forbidden')
311
+ }
312
+ return jsonError(500, 'Internal server error.', 'internal_error')
313
+ }
314
+ }
@@ -211,8 +211,16 @@ export async function GET(req: NextRequest, context: RouteContext): Promise<Resp
211
211
  if (!transcript) {
212
212
  return jsonError(404, 'Conversation not found.', 'conversation_not_found')
213
213
  }
214
+ const participantCount = await repo.getParticipantCount(
215
+ callerCtx.tenantId,
216
+ callerCtx.organizationId,
217
+ callerCtx.conversationId,
218
+ )
214
219
  return NextResponse.json({
215
- conversation: serializeAiChatConversation(transcript.conversation),
220
+ conversation: serializeAiChatConversation(transcript.conversation, {
221
+ callerUserId: callerCtx.userId,
222
+ participantCount,
223
+ }),
216
224
  messages: transcript.messages.map(serializeAiChatMessage),
217
225
  nextCursor: transcript.nextCursor,
218
226
  })
@@ -167,7 +167,7 @@ export async function GET(req: NextRequest): Promise<Response> {
167
167
  },
168
168
  )
169
169
  return NextResponse.json({
170
- items: result.items.map(serializeAiChatConversation),
170
+ items: result.items.map((row) => serializeAiChatConversation(row)),
171
171
  nextCursor: result.nextCursor,
172
172
  })
173
173
  } catch (error) {
@@ -228,6 +228,9 @@ export async function POST(req: NextRequest): Promise<Response> {
228
228
  const status = beforeRow ? 200 : 201
229
229
  return NextResponse.json(serializeAiChatConversation(row), { status })
230
230
  } catch (error) {
231
+ if (error instanceof Error && error.name === 'AiChatConversationOrgNotFoundError') {
232
+ return jsonError(400, error.message, 'organization_not_found')
233
+ }
231
234
  if (error instanceof Error && error.name === 'AiChatConversationAccessError') {
232
235
  return jsonError(404, error.message, 'conversation_not_found')
233
236
  }
@@ -15,6 +15,7 @@ import { EmptyState } from '@open-mercato/ui/backend/EmptyState'
15
15
  import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
16
16
  import { AiChat, createAiUiPartRegistry, LoopDisabledBanner, useAiShortcuts } from '@open-mercato/ui/ai'
17
17
  import type { AiChatDebugPromptSection, AiChatDebugTool } from '@open-mercato/ui/ai'
18
+ import { ConversationShareButton } from '../../../../components/ConversationShareButton'
18
19
 
19
20
  type PlaygroundAgentTool = {
20
21
  name: string
@@ -301,6 +302,7 @@ function ChatLane({ agent, debug }: { agent: PlaygroundAgent; debug: boolean })
301
302
  [agent],
302
303
  )
303
304
  const [uiParts, setUiParts] = React.useState<PlaygroundUiPartSeed[]>([])
305
+ const [conversationId, setConversationId] = React.useState<string | null>(null)
304
306
 
305
307
  // Step 5.10: the dispatcher does not yet surface `AiUiPart` entries through
306
308
  // the plain-text stream consumed by `useAiChat`. For now the playground
@@ -345,6 +347,8 @@ function ChatLane({ agent, debug }: { agent: PlaygroundAgent; debug: boolean })
345
347
  debugTools={debugTools}
346
348
  debugPromptSections={debugPromptSections}
347
349
  uiParts={uiParts}
350
+ onConversationIdChange={setConversationId}
351
+ headerActions={conversationId ? <ConversationShareButton conversationId={conversationId} /> : null}
348
352
  />
349
353
  </div>
350
354
  )
@@ -0,0 +1 @@
1
+ export { ConversationShareButton } from '@open-mercato/ui/ai'
@@ -0,0 +1 @@
1
+ export { ConversationShareDialog } from '@open-mercato/ui/ai'
@@ -800,6 +800,7 @@ export class AiChatConversationParticipant {
800
800
  | 'organizationId'
801
801
  | 'role'
802
802
  | 'lastReadAt'
803
+ | 'deletedAt'
803
804
 
804
805
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
805
806
  id!: string
@@ -822,6 +823,9 @@ export class AiChatConversationParticipant {
822
823
  @Property({ name: 'last_read_at', type: Date, nullable: true })
823
824
  lastReadAt?: Date | null
824
825
 
826
+ @Property({ name: 'deleted_at', type: Date, nullable: true })
827
+ deletedAt?: Date | null
828
+
825
829
  @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
826
830
  createdAt: Date = new Date()
827
831