@open-mercato/ai-assistant 0.6.3-develop.3901.1.ddad60693a → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-06-deep-link.spec.js +87 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-06-deep-link.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-07-owner-label.spec.js +119 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-07-owner-label.spec.js.map +7 -0
- package/dist/modules/ai_assistant/acl.js +1 -0
- package/dist/modules/ai_assistant/acl.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js +3 -0
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/[userId]/route.js +128 -0
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/[userId]/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/route.js +271 -0
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/route.js +9 -1
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/conversations/route.js +4 -1
- package/dist/modules/ai_assistant/api/ai/conversations/route.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +5 -1
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/components/ConversationShareButton.js +5 -0
- package/dist/modules/ai_assistant/components/ConversationShareButton.js.map +7 -0
- package/dist/modules/ai_assistant/components/ConversationShareDialog.js +5 -0
- package/dist/modules/ai_assistant/components/ConversationShareDialog.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities.js +3 -0
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js +235 -5
- package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js.map +2 -2
- package/dist/modules/ai_assistant/events.js +14 -0
- package/dist/modules/ai_assistant/events.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +17 -0
- package/dist/modules/ai_assistant/i18n/en.json +17 -0
- package/dist/modules/ai_assistant/i18n/es.json +17 -0
- package/dist/modules/ai_assistant/i18n/pl.json +17 -0
- package/dist/modules/ai_assistant/lib/conversation-storage.js +12 -3
- package/dist/modules/ai_assistant/lib/conversation-storage.js.map +2 -2
- package/dist/modules/ai_assistant/migrations/Migration20260522120000_ai_assistant.js +15 -0
- package/dist/modules/ai_assistant/migrations/Migration20260522120000_ai_assistant.js.map +7 -0
- package/dist/modules/ai_assistant/notifications.client.js +30 -0
- package/dist/modules/ai_assistant/notifications.client.js.map +7 -0
- package/dist/modules/ai_assistant/notifications.js +27 -0
- package/dist/modules/ai_assistant/notifications.js.map +7 -0
- package/dist/modules/ai_assistant/setup.js +2 -1
- package/dist/modules/ai_assistant/setup.js.map +2 -2
- package/dist/modules/ai_assistant/subscribers/conversation-shared-notify.js +59 -0
- package/dist/modules/ai_assistant/subscribers/conversation-shared-notify.js.map +7 -0
- package/dist/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.js +123 -0
- package/dist/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.js.map +7 -0
- package/generated/entities/ai_chat_conversation_participant/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -8
- package/src/modules/ai_assistant/__integration__/TC-AI-sharing-06-deep-link.spec.ts +117 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-sharing-07-owner-label.spec.ts +159 -0
- package/src/modules/ai_assistant/__tests__/integration/ai-chat-sharing.test.ts +406 -0
- package/src/modules/ai_assistant/acl.ts +1 -0
- package/src/modules/ai_assistant/api/ai/chat/route.ts +3 -0
- package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/[userId]/route.ts +149 -0
- package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/route.ts +314 -0
- package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/route.ts +9 -1
- package/src/modules/ai_assistant/api/ai/conversations/route.ts +4 -1
- package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +4 -0
- package/src/modules/ai_assistant/components/ConversationShareButton.tsx +1 -0
- package/src/modules/ai_assistant/components/ConversationShareDialog.tsx +1 -0
- package/src/modules/ai_assistant/data/entities.ts +4 -0
- package/src/modules/ai_assistant/data/repositories/AiChatConversationRepository.ts +270 -7
- package/src/modules/ai_assistant/data/repositories/__tests__/AiChatConversationRepository.test.ts +297 -3
- package/src/modules/ai_assistant/events.ts +31 -0
- package/src/modules/ai_assistant/i18n/__tests__/conversation-share-translations.test.ts +59 -0
- package/src/modules/ai_assistant/i18n/de.json +17 -0
- package/src/modules/ai_assistant/i18n/en.json +17 -0
- package/src/modules/ai_assistant/i18n/es.json +17 -0
- package/src/modules/ai_assistant/i18n/pl.json +17 -0
- package/src/modules/ai_assistant/lib/conversation-storage.ts +22 -1
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +25 -0
- package/src/modules/ai_assistant/migrations/Migration20260522120000_ai_assistant.ts +15 -0
- package/src/modules/ai_assistant/notifications.client.ts +29 -0
- package/src/modules/ai_assistant/notifications.ts +25 -0
- package/src/modules/ai_assistant/setup.ts +2 -1
- package/src/modules/ai_assistant/subscribers/__tests__/conversation-shared-notify.test.ts +116 -0
- package/src/modules/ai_assistant/subscribers/conversation-shared-notify.ts +78 -0
- 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
|
}
|
package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx
CHANGED
|
@@ -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
|
|