@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.
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,117 @@
1
+ import { test, expect } from '@playwright/test';
2
+ import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth';
3
+
4
+ /**
5
+ * TC-AI-sharing-06: Deep-link auto-open via ?openAiConversation=<id>
6
+ *
7
+ * Regression test for the bug where navigating to
8
+ * `/backend?openAiConversation=<id>` failed to open the AiAssistantLauncher
9
+ * chat sheet — because the launcher read window.location.search only on mount
10
+ * and AppShell never remounts during client-side navigation.
11
+ *
12
+ * Fix: replaced mount-only useEffect with useSearchParams() from next/navigation,
13
+ * which is reactive to URL changes in both hard and client-side navigations.
14
+ *
15
+ * Also verifies the URL-cleanup behaviour: after the deep-link is handled the
16
+ * launcher strips ?openAiConversation from the URL via router.replace() so
17
+ * subsequent normal chat opens don't inherit the shared conversation.
18
+ *
19
+ * API routes are fully stubbed — no live LLM is called.
20
+ */
21
+ test.describe('TC-AI-sharing-06: deep-link auto-open from ?openAiConversation', () => {
22
+ const STUB_CONV_ID = 'test-shared-conv-01';
23
+ const STUB_AGENT_ID = 'customers.account_assistant';
24
+
25
+ const agentsStub = {
26
+ agents: [
27
+ {
28
+ id: STUB_AGENT_ID,
29
+ moduleId: 'customers',
30
+ label: 'Account assistant',
31
+ description: 'Helps with customer accounts.',
32
+ executionMode: 'chat',
33
+ mutationPolicy: 'read-only',
34
+ allowedTools: [],
35
+ requiredFeatures: [],
36
+ acceptedMediaTypes: [],
37
+ hasOutputSchema: false,
38
+ },
39
+ ],
40
+ total: 1,
41
+ aiConfigured: true,
42
+ };
43
+
44
+ const conversationStub = {
45
+ conversation: {
46
+ conversationId: STUB_CONV_ID,
47
+ agentId: STUB_AGENT_ID,
48
+ title: 'Shared conversation',
49
+ status: 'open',
50
+ visibility: 'shared',
51
+ pageContext: null,
52
+ createdAt: new Date().toISOString(),
53
+ updatedAt: new Date().toISOString(),
54
+ lastMessageAt: null,
55
+ importedFromLocalAt: null,
56
+ isOwner: false,
57
+ },
58
+ messages: [],
59
+ nextCursor: null,
60
+ };
61
+
62
+ async function setupStubs(page: import('@playwright/test').Page) {
63
+ await page.route('**/api/ai_assistant/health', (route) =>
64
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ healthy: true }) }),
65
+ );
66
+ await page.route('**/api/ai_assistant/ai/agents', (route) =>
67
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(agentsStub) }),
68
+ );
69
+ await page.route(`**/api/ai_assistant/ai/conversations/${STUB_CONV_ID}**`, (route) =>
70
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(conversationStub) }),
71
+ );
72
+ }
73
+
74
+ test('chat sheet opens when page loads with ?openAiConversation param', async ({ page }) => {
75
+ await login(page, 'superadmin');
76
+ await setupStubs(page);
77
+
78
+ await page.goto(`/backend?openAiConversation=${STUB_CONV_ID}`, { waitUntil: 'domcontentloaded' });
79
+
80
+ await expect(page.locator('[data-ai-launcher-sheet]')).toBeVisible({ timeout: 10_000 });
81
+ });
82
+
83
+ test('URL param is stripped after deep-link is handled', async ({ page }) => {
84
+ await login(page, 'superadmin');
85
+ await setupStubs(page);
86
+
87
+ await page.goto(`/backend?openAiConversation=${STUB_CONV_ID}`, { waitUntil: 'domcontentloaded' });
88
+
89
+ // Wait for the chat to open (deep-link was handled).
90
+ await expect(page.locator('[data-ai-launcher-sheet]')).toBeVisible({ timeout: 10_000 });
91
+
92
+ // The launcher calls router.replace(pathname) after opening, stripping the
93
+ // param. The URL should no longer contain openAiConversation.
94
+ await expect(page).not.toHaveURL(/openAiConversation/, { timeout: 5_000 });
95
+ });
96
+
97
+ test('deep-link is ignored when conversation fetch returns 403', async ({ page }) => {
98
+ await login(page, 'superadmin');
99
+
100
+ await page.route('**/api/ai_assistant/health', (route) =>
101
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ healthy: true }) }),
102
+ );
103
+ await page.route('**/api/ai_assistant/ai/agents', (route) =>
104
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(agentsStub) }),
105
+ );
106
+ // Simulate conversation that the viewer has no access to.
107
+ await page.route(`**/api/ai_assistant/ai/conversations/${STUB_CONV_ID}**`, (route) =>
108
+ route.fulfill({ status: 403, contentType: 'application/json', body: JSON.stringify({ error: 'forbidden' }) }),
109
+ );
110
+
111
+ await page.goto(`/backend?openAiConversation=${STUB_CONV_ID}`, { waitUntil: 'domcontentloaded' });
112
+
113
+ // Launcher should be visible but chat sheet must NOT auto-open.
114
+ await expect(page.locator('[data-ai-launcher-trigger]')).toBeVisible({ timeout: 10_000 });
115
+ await expect(page.locator('[data-ai-launcher-sheet]')).not.toBeVisible();
116
+ });
117
+ });
@@ -0,0 +1,159 @@
1
+ import { test, expect } from '@playwright/test';
2
+ import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth';
3
+
4
+ /**
5
+ * TC-AI-sharing-07: Owner message label in shared-conversation viewer mode
6
+ *
7
+ * Regression test for the bug where user-role messages in a shared
8
+ * conversation always rendered as "You" for the viewer, even when they were
9
+ * written by the conversation owner and had no senderUserId (common for
10
+ * conversations imported from local storage).
11
+ *
12
+ * Root cause: `isOtherUsersMessage` included `&& message.senderUserId != null`,
13
+ * but imported messages don't carry senderUserId. Since viewers can never send
14
+ * messages (the composer is hidden), ALL user-role messages when isOwner===false
15
+ * belong to the owner.
16
+ *
17
+ * Fix: removed the senderUserId guard from MessageRow in AiChat.tsx.
18
+ *
19
+ * Assertions:
20
+ * - [data-ai-chat-read-only-notice] is visible (confirms isOwner===false path)
21
+ * - user-role messages show "Owner" label, not "You" / "Ty"
22
+ * - assistant messages show "Assistant"
23
+ * - composer is hidden (read-only viewer mode)
24
+ */
25
+ test.describe('TC-AI-sharing-07: viewer sees Owner label on owner messages', () => {
26
+ const STUB_CONV_ID = 'test-shared-conv-02';
27
+ const STUB_AGENT_ID = 'customers.account_assistant';
28
+
29
+ const agentsStub = {
30
+ agents: [
31
+ {
32
+ id: STUB_AGENT_ID,
33
+ moduleId: 'customers',
34
+ label: 'Account assistant',
35
+ description: 'Helps with customer accounts.',
36
+ executionMode: 'chat',
37
+ mutationPolicy: 'read-only',
38
+ allowedTools: [],
39
+ requiredFeatures: [],
40
+ acceptedMediaTypes: [],
41
+ hasOutputSchema: false,
42
+ },
43
+ ],
44
+ total: 1,
45
+ aiConfigured: true,
46
+ };
47
+
48
+ // Conversation where isOwner===false (viewer). Messages have senderUserId:null
49
+ // because they were imported from local storage — this was the triggering
50
+ // condition of the bug.
51
+ const conversationStub = {
52
+ conversation: {
53
+ conversationId: STUB_CONV_ID,
54
+ agentId: STUB_AGENT_ID,
55
+ title: 'Shared conversation',
56
+ status: 'open',
57
+ visibility: 'shared',
58
+ pageContext: null,
59
+ createdAt: new Date().toISOString(),
60
+ updatedAt: new Date().toISOString(),
61
+ lastMessageAt: null,
62
+ importedFromLocalAt: new Date().toISOString(),
63
+ isOwner: false,
64
+ },
65
+ messages: [
66
+ {
67
+ id: 'msg-u1',
68
+ clientMessageId: 'cmsg-u1',
69
+ role: 'user',
70
+ content: 'What are the open deals for Acme?',
71
+ uiParts: [],
72
+ attachmentIds: [],
73
+ files: [],
74
+ model: null,
75
+ metadata: null,
76
+ createdAt: new Date().toISOString(),
77
+ // Deliberately null — imported messages never have senderUserId.
78
+ senderUserId: null,
79
+ },
80
+ {
81
+ id: 'msg-a1',
82
+ clientMessageId: 'cmsg-a1',
83
+ role: 'assistant',
84
+ content: 'I found 3 open deals for Acme Corp.',
85
+ uiParts: [],
86
+ attachmentIds: [],
87
+ files: [],
88
+ model: 'claude-haiku-4-5',
89
+ metadata: null,
90
+ createdAt: new Date().toISOString(),
91
+ senderUserId: null,
92
+ },
93
+ ],
94
+ nextCursor: null,
95
+ };
96
+
97
+ test('viewer sees Owner/Assistant labels and read-only notice', async ({ page }) => {
98
+ await login(page, 'superadmin');
99
+
100
+ await page.route('**/api/ai_assistant/health', (route) =>
101
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ healthy: true }) }),
102
+ );
103
+ await page.route('**/api/ai_assistant/ai/agents', (route) =>
104
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(agentsStub) }),
105
+ );
106
+ await page.route(`**/api/ai_assistant/ai/conversations/${STUB_CONV_ID}**`, (route) =>
107
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(conversationStub) }),
108
+ );
109
+
110
+ await page.goto(`/backend?openAiConversation=${STUB_CONV_ID}`, { waitUntil: 'domcontentloaded' });
111
+
112
+ const sheet = page.locator('[data-ai-launcher-sheet]');
113
+ await expect(sheet).toBeVisible({ timeout: 10_000 });
114
+
115
+ // Read-only notice must be present for a viewer.
116
+ await expect(sheet.locator('[data-ai-chat-read-only-notice]')).toBeVisible();
117
+
118
+ // User-role messages (from owner, senderUserId=null) must show "Owner", not "You".
119
+ const userMessages = sheet.locator('[data-role="user"]');
120
+ await expect(userMessages.first()).toBeVisible();
121
+ // The label is the first text-xs div inside the message header.
122
+ const userLabel = userMessages.first().locator('div.text-xs').first();
123
+ await expect(userLabel).toHaveText(/owner/i);
124
+ // Regression guard: must NOT show "You" or "Ty".
125
+ await expect(userLabel).not.toHaveText(/^you$/i);
126
+
127
+ // Assistant messages must still show "Assistant".
128
+ const assistantMessages = sheet.locator('[data-role="assistant"]');
129
+ await expect(assistantMessages.first()).toBeVisible();
130
+ const assistantLabel = assistantMessages.first().locator('div.text-xs').first();
131
+ await expect(assistantLabel).toHaveText(/assistant/i);
132
+ });
133
+
134
+ test('viewer composer is hidden (cannot send messages)', async ({ page }) => {
135
+ await login(page, 'superadmin');
136
+
137
+ await page.route('**/api/ai_assistant/health', (route) =>
138
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ healthy: true }) }),
139
+ );
140
+ await page.route('**/api/ai_assistant/ai/agents', (route) =>
141
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(agentsStub) }),
142
+ );
143
+ await page.route(`**/api/ai_assistant/ai/conversations/${STUB_CONV_ID}**`, (route) =>
144
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(conversationStub) }),
145
+ );
146
+
147
+ await page.goto(`/backend?openAiConversation=${STUB_CONV_ID}`, { waitUntil: 'domcontentloaded' });
148
+
149
+ const sheet = page.locator('[data-ai-launcher-sheet]');
150
+ await expect(sheet).toBeVisible({ timeout: 10_000 });
151
+
152
+ // The composer form is hidden via className when isOwner===false.
153
+ // It should not be visible to the user.
154
+ const composer = sheet.locator('form[data-ai-chat-composer], [data-ai-chat-composer]');
155
+ // Either not present or hidden.
156
+ const isComposerVisible = await composer.isVisible().catch(() => false);
157
+ expect(isComposerVisible).toBe(false);
158
+ });
159
+ });
@@ -0,0 +1,406 @@
1
+ import {
2
+ AiChatConversation,
3
+ AiChatConversationParticipant,
4
+ AiChatMessage,
5
+ } from '../../data/entities'
6
+ import {
7
+ AiChatConversationRepository,
8
+ } from '../../data/repositories/AiChatConversationRepository'
9
+
10
+ // ─── In-memory ORM mock ────────────────────────────────────────────────────
11
+
12
+ type ConvRow = {
13
+ id: string
14
+ tenantId: string
15
+ organizationId: string | null
16
+ conversationId: string
17
+ agentId: string
18
+ ownerUserId: string
19
+ title: string | null
20
+ status: 'open' | 'closed'
21
+ visibility: 'private' | 'shared' | 'organization'
22
+ pageContext: Record<string, unknown> | null
23
+ lastMessageAt: Date | null
24
+ importedFromLocalAt: Date | null
25
+ createdAt: Date
26
+ updatedAt: Date
27
+ deletedAt: Date | null
28
+ }
29
+
30
+ type ParticipantRow = {
31
+ id: string
32
+ tenantId: string
33
+ organizationId: string | null
34
+ conversationId: string
35
+ userId: string
36
+ role: 'owner' | 'viewer' | 'commenter'
37
+ lastReadAt: Date | null
38
+ deletedAt: Date | null
39
+ createdAt: Date
40
+ updatedAt: Date
41
+ }
42
+
43
+ let idCounter = 0
44
+
45
+ function matchesWhere(row: Record<string, any>, where: any): boolean {
46
+ if (!where) return true
47
+ for (const key of Object.keys(where)) {
48
+ if (key === '$or') {
49
+ const branches: any[] = where.$or
50
+ if (!branches.some((branch) => matchesWhere(row, branch))) return false
51
+ continue
52
+ }
53
+ const expected = where[key]
54
+ const actual = row[key] ?? null
55
+ if (expected && typeof expected === 'object') {
56
+ if ('$lt' in expected) {
57
+ const lt = expected.$lt as Date
58
+ if (!(actual instanceof Date) || !(actual.getTime() < lt.getTime())) return false
59
+ continue
60
+ }
61
+ if ('$in' in expected) {
62
+ const list = expected.$in as unknown[]
63
+ if (!list.includes(actual)) return false
64
+ continue
65
+ }
66
+ if ('$ne' in expected) {
67
+ if (actual === expected.$ne) return false
68
+ continue
69
+ }
70
+ }
71
+ if (expected === null) {
72
+ if (actual !== null && actual !== undefined) return false
73
+ continue
74
+ }
75
+ if (actual !== expected) return false
76
+ }
77
+ return true
78
+ }
79
+
80
+ function entityKey(entity: unknown): 'conv' | 'participant' | 'message' | null {
81
+ if (entity === AiChatConversation) return 'conv'
82
+ if (entity === AiChatConversationParticipant) return 'participant'
83
+ if (entity === AiChatMessage) return 'message'
84
+ return null
85
+ }
86
+
87
+ function mockEm() {
88
+ const stores: Record<'conv' | 'participant' | 'message', any[]> = {
89
+ conv: [],
90
+ participant: [],
91
+ message: [],
92
+ }
93
+
94
+ const find = async (entity: unknown, where: any, options?: any): Promise<any[]> => {
95
+ const key = entityKey(entity)
96
+ if (!key) return []
97
+ let rows = stores[key].filter((row) => matchesWhere(row, where))
98
+ if (typeof options?.limit === 'number') rows = rows.slice(0, options.limit)
99
+ return rows
100
+ }
101
+
102
+ const pendingPersist: any[] = []
103
+
104
+ const em: any = {
105
+ find,
106
+ findOne: async (entity: unknown, where: any) => {
107
+ const rows = await find(entity, where)
108
+ return rows[0] ?? null
109
+ },
110
+ count: async (entity: unknown, where: any): Promise<number> => {
111
+ const rows = await find(entity, where)
112
+ return rows.length
113
+ },
114
+ create: (entity: unknown, data: any) => {
115
+ idCounter += 1
116
+ const key = entityKey(entity)
117
+ if (key === 'conv') {
118
+ const row: ConvRow = {
119
+ id: `conv-${idCounter}`,
120
+ tenantId: data.tenantId,
121
+ organizationId: data.organizationId ?? null,
122
+ conversationId: data.conversationId,
123
+ agentId: data.agentId ?? 'test-agent',
124
+ ownerUserId: data.ownerUserId,
125
+ title: data.title ?? null,
126
+ status: data.status ?? 'open',
127
+ visibility: data.visibility ?? 'private',
128
+ pageContext: data.pageContext ?? null,
129
+ lastMessageAt: data.lastMessageAt ?? null,
130
+ importedFromLocalAt: data.importedFromLocalAt ?? null,
131
+ createdAt: new Date(),
132
+ updatedAt: new Date(),
133
+ deletedAt: data.deletedAt ?? null,
134
+ }
135
+ return row
136
+ }
137
+ if (key === 'participant') {
138
+ const row: ParticipantRow = {
139
+ id: `part-${idCounter}`,
140
+ tenantId: data.tenantId,
141
+ organizationId: data.organizationId ?? null,
142
+ conversationId: data.conversationId,
143
+ userId: data.userId,
144
+ role: data.role ?? 'owner',
145
+ lastReadAt: data.lastReadAt ?? null,
146
+ deletedAt: data.deletedAt ?? null,
147
+ createdAt: new Date(),
148
+ updatedAt: new Date(),
149
+ }
150
+ return row
151
+ }
152
+ throw new Error(`Unknown entity in mock EM`)
153
+ },
154
+ persist: (row: any) => {
155
+ pendingPersist.push(row)
156
+ return em
157
+ },
158
+ flush: async () => {
159
+ while (pendingPersist.length > 0) {
160
+ const row = pendingPersist.shift()
161
+ if (!row) continue
162
+ const key: 'conv' | 'participant' | 'message' = row.id.startsWith('conv-')
163
+ ? 'conv'
164
+ : row.id.startsWith('part-')
165
+ ? 'participant'
166
+ : 'message'
167
+ const store = stores[key]
168
+ const idx = store.findIndex((c) => c.id === row.id)
169
+ if (idx >= 0) store[idx] = row
170
+ else store.push(row)
171
+ }
172
+ },
173
+ transactional: async (fn: (tx: any) => Promise<unknown>) => fn(em),
174
+ __stores: stores,
175
+ }
176
+ return em
177
+ }
178
+
179
+ // ─── Helpers ──────────────────────────────────────────────────────────────
180
+
181
+ function seedConversation(
182
+ em: ReturnType<typeof mockEm>,
183
+ overrides: Partial<ConvRow> & { conversationId: string; tenantId: string; ownerUserId: string },
184
+ ) {
185
+ idCounter += 1
186
+ const row: ConvRow = {
187
+ id: `conv-${idCounter}`,
188
+ organizationId: null,
189
+ agentId: 'test-agent',
190
+ title: null,
191
+ status: 'open',
192
+ visibility: 'private',
193
+ pageContext: null,
194
+ lastMessageAt: null,
195
+ importedFromLocalAt: null,
196
+ createdAt: new Date(),
197
+ updatedAt: new Date(),
198
+ deletedAt: null,
199
+ ...overrides,
200
+ }
201
+ em.__stores.conv.push(row)
202
+ return row
203
+ }
204
+
205
+ function seedParticipant(
206
+ em: ReturnType<typeof mockEm>,
207
+ overrides: Partial<ParticipantRow> & { conversationId: string; tenantId: string; userId: string },
208
+ ) {
209
+ idCounter += 1
210
+ const row: ParticipantRow = {
211
+ id: `part-${idCounter}`,
212
+ organizationId: null,
213
+ role: 'viewer',
214
+ lastReadAt: null,
215
+ deletedAt: null,
216
+ createdAt: new Date(),
217
+ updatedAt: new Date(),
218
+ ...overrides,
219
+ }
220
+ em.__stores.participant.push(row)
221
+ return row
222
+ }
223
+
224
+ // ─── Test suite ───────────────────────────────────────────────────────────
225
+
226
+ const TENANT_A = 'tenant-alpha'
227
+ const TENANT_B = 'tenant-beta'
228
+ const CONV_ID = 'conv-share-001'
229
+ const OWNER_ID = 'user-owner'
230
+ const VIEWER_ID = 'user-viewer'
231
+ const STRANGER_ID = 'user-stranger'
232
+
233
+ describe('TC-AI-sharing-01: owner access baseline', () => {
234
+ it('owner can retrieve their own conversation via getById', async () => {
235
+ const em = mockEm()
236
+ seedConversation(em, {
237
+ conversationId: CONV_ID,
238
+ tenantId: TENANT_A,
239
+ ownerUserId: OWNER_ID,
240
+ })
241
+
242
+ const repo = new AiChatConversationRepository(em)
243
+ const result = await repo.getById(CONV_ID, {
244
+ tenantId: TENANT_A,
245
+ organizationId: null,
246
+ userId: OWNER_ID,
247
+ canManageConversations: false,
248
+ })
249
+
250
+ expect(result).not.toBeNull()
251
+ expect(result?.conversationId).toBe(CONV_ID)
252
+ expect(result?.ownerUserId).toBe(OWNER_ID)
253
+ })
254
+ })
255
+
256
+ describe('TC-AI-sharing-02: participant access', () => {
257
+ it('viewer participant can access a shared conversation via getById', async () => {
258
+ const em = mockEm()
259
+ seedConversation(em, {
260
+ conversationId: CONV_ID,
261
+ tenantId: TENANT_A,
262
+ ownerUserId: OWNER_ID,
263
+ visibility: 'shared',
264
+ })
265
+ seedParticipant(em, {
266
+ conversationId: CONV_ID,
267
+ tenantId: TENANT_A,
268
+ userId: VIEWER_ID,
269
+ role: 'viewer',
270
+ })
271
+
272
+ const repo = new AiChatConversationRepository(em)
273
+ const result = await repo.getById(CONV_ID, {
274
+ tenantId: TENANT_A,
275
+ organizationId: null,
276
+ userId: VIEWER_ID,
277
+ canManageConversations: false,
278
+ })
279
+
280
+ expect(result).not.toBeNull()
281
+ expect(result?.conversationId).toBe(CONV_ID)
282
+ })
283
+
284
+ it('participant also sees shared conversation in list', async () => {
285
+ const em = mockEm()
286
+ seedConversation(em, {
287
+ conversationId: CONV_ID,
288
+ tenantId: TENANT_A,
289
+ ownerUserId: OWNER_ID,
290
+ visibility: 'shared',
291
+ })
292
+ seedParticipant(em, {
293
+ conversationId: CONV_ID,
294
+ tenantId: TENANT_A,
295
+ userId: VIEWER_ID,
296
+ role: 'viewer',
297
+ })
298
+
299
+ const repo = new AiChatConversationRepository(em)
300
+ const { items } = await repo.list(
301
+ { tenantId: TENANT_A, organizationId: null, userId: VIEWER_ID, canManageConversations: false },
302
+ )
303
+
304
+ expect(items.some((c) => c.conversationId === CONV_ID)).toBe(true)
305
+ })
306
+ })
307
+
308
+ describe('TC-AI-sharing-03: non-participant denial', () => {
309
+ it('user without participant row cannot access another user\'s conversation', async () => {
310
+ const em = mockEm()
311
+ seedConversation(em, {
312
+ conversationId: CONV_ID,
313
+ tenantId: TENANT_A,
314
+ ownerUserId: OWNER_ID,
315
+ })
316
+
317
+ const repo = new AiChatConversationRepository(em)
318
+ const result = await repo.getById(CONV_ID, {
319
+ tenantId: TENANT_A,
320
+ organizationId: null,
321
+ userId: STRANGER_ID,
322
+ canManageConversations: false,
323
+ })
324
+
325
+ expect(result).toBeNull()
326
+ })
327
+
328
+ it('revoked participant (deletedAt set) cannot access the conversation', async () => {
329
+ const em = mockEm()
330
+ seedConversation(em, {
331
+ conversationId: CONV_ID,
332
+ tenantId: TENANT_A,
333
+ ownerUserId: OWNER_ID,
334
+ visibility: 'shared',
335
+ })
336
+ seedParticipant(em, {
337
+ conversationId: CONV_ID,
338
+ tenantId: TENANT_A,
339
+ userId: VIEWER_ID,
340
+ role: 'viewer',
341
+ deletedAt: new Date(),
342
+ })
343
+
344
+ const repo = new AiChatConversationRepository(em)
345
+ const result = await repo.getById(CONV_ID, {
346
+ tenantId: TENANT_A,
347
+ organizationId: null,
348
+ userId: VIEWER_ID,
349
+ canManageConversations: false,
350
+ })
351
+
352
+ expect(result).toBeNull()
353
+ })
354
+ })
355
+
356
+ describe('TC-AI-sharing-04: manager override', () => {
357
+ it('manager (canManageConversations=true) can access any conversation in their tenant', async () => {
358
+ const em = mockEm()
359
+ seedConversation(em, {
360
+ conversationId: CONV_ID,
361
+ tenantId: TENANT_A,
362
+ ownerUserId: OWNER_ID,
363
+ })
364
+
365
+ const repo = new AiChatConversationRepository(em)
366
+ const result = await repo.getById(CONV_ID, {
367
+ tenantId: TENANT_A,
368
+ organizationId: null,
369
+ userId: STRANGER_ID,
370
+ canManageConversations: true,
371
+ })
372
+
373
+ expect(result).not.toBeNull()
374
+ expect(result?.conversationId).toBe(CONV_ID)
375
+ })
376
+ })
377
+
378
+ describe('TC-AI-sharing-05: cross-tenant denial', () => {
379
+ it('participant row from a different tenant does not grant access', async () => {
380
+ const em = mockEm()
381
+ seedConversation(em, {
382
+ conversationId: CONV_ID,
383
+ tenantId: TENANT_A,
384
+ ownerUserId: OWNER_ID,
385
+ })
386
+ // Malicious scenario: participant row for VIEWER_ID but with TENANT_B tenant scope
387
+ seedParticipant(em, {
388
+ conversationId: CONV_ID,
389
+ tenantId: TENANT_B,
390
+ userId: VIEWER_ID,
391
+ role: 'viewer',
392
+ })
393
+
394
+ const repo = new AiChatConversationRepository(em)
395
+ // VIEWER_ID attempts access via TENANT_B context — should fail because the
396
+ // conversation belongs to TENANT_A, so findOneAccessibleConversation returns null.
397
+ const result = await repo.getById(CONV_ID, {
398
+ tenantId: TENANT_B,
399
+ organizationId: null,
400
+ userId: VIEWER_ID,
401
+ canManageConversations: false,
402
+ })
403
+
404
+ expect(result).toBeNull()
405
+ })
406
+ })
@@ -2,6 +2,7 @@ export const features = [
2
2
  { id: 'ai_assistant.view', title: 'View AI Assistant Settings', module: 'ai_assistant' },
3
3
  { id: 'ai_assistant.settings.manage', title: 'Manage AI Assistant Settings', module: 'ai_assistant' },
4
4
  { id: 'ai_assistant.conversations.manage', title: 'Manage AI Assistant Conversations', module: 'ai_assistant' },
5
+ { id: 'ai_assistant.conversations.share', title: 'Share AI Assistant Conversations', module: 'ai_assistant' },
5
6
  { id: 'ai_assistant.mcp.serve', title: 'Start MCP Server', module: 'ai_assistant' },
6
7
  { id: 'ai_assistant.tools.list', title: 'List MCP Tools', module: 'ai_assistant' },
7
8
  { id: 'ai_assistant.mcp_servers.view', title: 'View MCP Server Configurations', module: 'ai_assistant' },
@@ -650,6 +650,9 @@ export async function POST(req: NextRequest): Promise<Response> {
650
650
  attachmentIds: bodyResult.data.attachmentIds,
651
651
  })
652
652
  } catch (error) {
653
+ if (error instanceof Error && error.name === 'AiChatConversationOrgNotFoundError') {
654
+ return jsonError(400, error.message, 'organization_not_found')
655
+ }
653
656
  console.error('[AI Chat Agent] Failed to persist user message:', error)
654
657
  }
655
658