@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
@@ -0,0 +1,592 @@
1
+ import {
2
+ AiChatConversation,
3
+ AiChatConversationParticipant,
4
+ AiChatMessage,
5
+ } from '../../entities'
6
+ import {
7
+ AiChatConversationAccessError,
8
+ AiChatConversationRepository,
9
+ } from '../AiChatConversationRepository'
10
+
11
+ type ConvRow = {
12
+ id: string
13
+ tenantId: string
14
+ organizationId: string | null
15
+ conversationId: string
16
+ agentId: string
17
+ ownerUserId: string
18
+ title: string | null
19
+ status: 'open' | 'closed'
20
+ visibility: 'private' | 'shared' | 'organization'
21
+ pageContext: Record<string, unknown> | null
22
+ lastMessageAt: Date | null
23
+ importedFromLocalAt: Date | null
24
+ createdAt: Date
25
+ updatedAt: Date
26
+ deletedAt: Date | null
27
+ }
28
+
29
+ type ParticipantRow = {
30
+ id: string
31
+ tenantId: string
32
+ organizationId: string | null
33
+ conversationId: string
34
+ userId: string
35
+ role: 'owner' | 'viewer' | 'commenter'
36
+ lastReadAt: Date | null
37
+ createdAt: Date
38
+ updatedAt: Date
39
+ }
40
+
41
+ type MessageRow = {
42
+ id: string
43
+ tenantId: string
44
+ organizationId: string | null
45
+ conversationId: string
46
+ clientMessageId: string | null
47
+ role: 'user' | 'assistant' | 'system'
48
+ content: string
49
+ uiParts: unknown[] | null
50
+ attachmentIds: string[] | null
51
+ filesMetadata: Array<Record<string, unknown>> | null
52
+ model: string | null
53
+ metadata: Record<string, unknown> | null
54
+ createdByUserId: string | null
55
+ createdAt: Date
56
+ updatedAt: Date
57
+ deletedAt: Date | null
58
+ }
59
+
60
+ let idCounter = 0
61
+
62
+ function matchesWhere(row: Record<string, any>, where: any): boolean {
63
+ if (!where) return true
64
+ for (const key of Object.keys(where)) {
65
+ const expected = where[key]
66
+ const actual = row[key] ?? null
67
+ if (expected && typeof expected === 'object' && '$lt' in expected) {
68
+ const lt = expected.$lt as Date
69
+ if (!(actual instanceof Date) || !(actual.getTime() < lt.getTime())) return false
70
+ continue
71
+ }
72
+ if (expected === null) {
73
+ if (actual !== null && actual !== undefined) return false
74
+ continue
75
+ }
76
+ if (actual !== expected) return false
77
+ }
78
+ return true
79
+ }
80
+
81
+ function applyOrder<T extends Record<string, any>>(rows: T[], orderBy: any): T[] {
82
+ if (!orderBy) return rows
83
+ const clauses = Array.isArray(orderBy) ? orderBy : [orderBy]
84
+ return [...rows].sort((a, b) => {
85
+ for (const clause of clauses) {
86
+ for (const [field, direction] of Object.entries(clause)) {
87
+ const av = a[field]
88
+ const bv = b[field]
89
+ const aTime = av instanceof Date ? av.getTime() : av ?? null
90
+ const bTime = bv instanceof Date ? bv.getTime() : bv ?? null
91
+ if (aTime === bTime) continue
92
+ if (aTime === null) return direction === 'asc' ? -1 : 1
93
+ if (bTime === null) return direction === 'asc' ? 1 : -1
94
+ if (aTime < bTime) return direction === 'asc' ? -1 : 1
95
+ return direction === 'asc' ? 1 : -1
96
+ }
97
+ }
98
+ return 0
99
+ })
100
+ }
101
+
102
+ function entityKey(entity: unknown): 'conv' | 'participant' | 'message' | null {
103
+ if (entity === AiChatConversation) return 'conv'
104
+ if (entity === AiChatConversationParticipant) return 'participant'
105
+ if (entity === AiChatMessage) return 'message'
106
+ return null
107
+ }
108
+
109
+ function mockEm() {
110
+ const stores: Record<'conv' | 'participant' | 'message', any[]> = {
111
+ conv: [],
112
+ participant: [],
113
+ message: [],
114
+ }
115
+
116
+ const find = async (entity: unknown, where: any, options?: any): Promise<any[]> => {
117
+ const key = entityKey(entity)
118
+ if (!key) return []
119
+ let rows = stores[key].filter((row) => matchesWhere(row, where))
120
+ rows = applyOrder(rows, options?.orderBy)
121
+ if (typeof options?.limit === 'number') rows = rows.slice(0, options.limit)
122
+ return rows
123
+ }
124
+
125
+ const pendingPersist: any[] = []
126
+
127
+ const em: any = {
128
+ find,
129
+ findOne: async (entity: unknown, where: any, options?: any) => {
130
+ const rows = await find(entity, where, options)
131
+ return rows[0] ?? null
132
+ },
133
+ create: (entity: unknown, data: any) => {
134
+ idCounter += 1
135
+ const key = entityKey(entity)
136
+ if (!key) throw new Error(`Unknown entity in mock EM`)
137
+ if (key === 'conv') {
138
+ const row: ConvRow = {
139
+ id: `conv-${idCounter}`,
140
+ tenantId: data.tenantId,
141
+ organizationId: data.organizationId ?? null,
142
+ conversationId: data.conversationId,
143
+ agentId: data.agentId,
144
+ ownerUserId: data.ownerUserId,
145
+ title: data.title ?? null,
146
+ status: data.status ?? 'open',
147
+ visibility: data.visibility ?? 'private',
148
+ pageContext: data.pageContext ?? null,
149
+ lastMessageAt: data.lastMessageAt ?? null,
150
+ importedFromLocalAt: data.importedFromLocalAt ?? null,
151
+ createdAt: data.createdAt instanceof Date ? data.createdAt : new Date(),
152
+ updatedAt: data.updatedAt instanceof Date ? data.updatedAt : new Date(),
153
+ deletedAt: data.deletedAt ?? null,
154
+ }
155
+ return row
156
+ }
157
+ if (key === 'participant') {
158
+ const row: ParticipantRow = {
159
+ id: `part-${idCounter}`,
160
+ tenantId: data.tenantId,
161
+ organizationId: data.organizationId ?? null,
162
+ conversationId: data.conversationId,
163
+ userId: data.userId,
164
+ role: data.role ?? 'owner',
165
+ lastReadAt: data.lastReadAt ?? null,
166
+ createdAt: data.createdAt instanceof Date ? data.createdAt : new Date(),
167
+ updatedAt: data.updatedAt instanceof Date ? data.updatedAt : new Date(),
168
+ }
169
+ return row
170
+ }
171
+ const row: MessageRow = {
172
+ id: `msg-${idCounter}`,
173
+ tenantId: data.tenantId,
174
+ organizationId: data.organizationId ?? null,
175
+ conversationId: data.conversationId,
176
+ clientMessageId: data.clientMessageId ?? null,
177
+ role: data.role,
178
+ content: data.content,
179
+ uiParts: data.uiParts ?? null,
180
+ attachmentIds: data.attachmentIds ?? null,
181
+ filesMetadata: data.filesMetadata ?? null,
182
+ model: data.model ?? null,
183
+ metadata: data.metadata ?? null,
184
+ createdByUserId: data.createdByUserId ?? null,
185
+ createdAt: data.createdAt instanceof Date ? data.createdAt : new Date(),
186
+ updatedAt: data.updatedAt instanceof Date ? data.updatedAt : new Date(),
187
+ deletedAt: data.deletedAt ?? null,
188
+ }
189
+ return row
190
+ },
191
+ persist: (row: any) => {
192
+ pendingPersist.push(row)
193
+ return em
194
+ },
195
+ flush: async () => {
196
+ while (pendingPersist.length > 0) {
197
+ const row = pendingPersist.shift()
198
+ if (!row) continue
199
+ const key: 'conv' | 'participant' | 'message' = row.id.startsWith('conv-')
200
+ ? 'conv'
201
+ : row.id.startsWith('part-')
202
+ ? 'participant'
203
+ : 'message'
204
+ const store = stores[key]
205
+ const idx = store.findIndex((candidate) => candidate.id === row.id)
206
+ if (idx >= 0) store[idx] = row
207
+ else store.push(row)
208
+ }
209
+ },
210
+ transactional: async (fn: (tx: any) => Promise<unknown>) => fn(em),
211
+ __stores: stores,
212
+ }
213
+
214
+ return em
215
+ }
216
+
217
+ const tenantAlpha = 't-alpha'
218
+ const tenantBeta = 't-beta'
219
+
220
+ describe('AiChatConversationRepository', () => {
221
+ it('createOrGet writes an owner participant row in the same transaction', async () => {
222
+ const em = mockEm()
223
+ const repo = new AiChatConversationRepository(em)
224
+ const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
225
+
226
+ const row = await repo.createOrGet(
227
+ {
228
+ conversationId: 'conv-1',
229
+ agentId: 'catalog.merchandising_assistant',
230
+ title: 'Pricing work',
231
+ },
232
+ ctx,
233
+ )
234
+
235
+ expect(row.conversationId).toBe('conv-1')
236
+ expect(row.ownerUserId).toBe('u-1')
237
+ expect(row.status).toBe('open')
238
+ expect(em.__stores.participant).toHaveLength(1)
239
+ expect(em.__stores.participant[0].userId).toBe('u-1')
240
+ expect(em.__stores.participant[0].role).toBe('owner')
241
+ })
242
+
243
+ it('createOrGet is idempotent for the same caller within a tenant', async () => {
244
+ const em = mockEm()
245
+ const repo = new AiChatConversationRepository(em)
246
+ const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
247
+ const first = await repo.createOrGet(
248
+ { conversationId: 'conv-42', agentId: 'catalog.merchandising_assistant' },
249
+ ctx,
250
+ )
251
+ const second = await repo.createOrGet(
252
+ { conversationId: 'conv-42', agentId: 'catalog.merchandising_assistant' },
253
+ ctx,
254
+ )
255
+ expect(second.id).toBe(first.id)
256
+ expect(em.__stores.conv).toHaveLength(1)
257
+ expect(em.__stores.participant).toHaveLength(1)
258
+ })
259
+
260
+ it('createOrGet refuses to surface a conversation owned by a different user (cross-user denial)', async () => {
261
+ const em = mockEm()
262
+ const repo = new AiChatConversationRepository(em)
263
+ const ctx1 = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
264
+ const ctx2 = { tenantId: tenantAlpha, organizationId: null, userId: 'u-2' }
265
+ await repo.createOrGet(
266
+ { conversationId: 'conv-x', agentId: 'catalog.merchandising_assistant' },
267
+ ctx1,
268
+ )
269
+ await expect(
270
+ repo.createOrGet(
271
+ { conversationId: 'conv-x', agentId: 'catalog.merchandising_assistant' },
272
+ ctx2,
273
+ ),
274
+ ).rejects.toBeInstanceOf(AiChatConversationAccessError)
275
+ })
276
+
277
+ it('getById returns null for cross-tenant lookups even when the conversation id collides', async () => {
278
+ const em = mockEm()
279
+ const repo = new AiChatConversationRepository(em)
280
+ await repo.createOrGet(
281
+ { conversationId: 'duplicate-id', agentId: 'a' },
282
+ { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' },
283
+ )
284
+ await repo.createOrGet(
285
+ { conversationId: 'duplicate-id', agentId: 'a' },
286
+ { tenantId: tenantBeta, organizationId: null, userId: 'u-2' },
287
+ )
288
+ const result = await repo.getById('duplicate-id', {
289
+ tenantId: tenantAlpha,
290
+ organizationId: null,
291
+ userId: 'u-2',
292
+ })
293
+ expect(result).toBeNull()
294
+ })
295
+
296
+ it('appendMessage with the same clientMessageId returns the existing row without duplicating', async () => {
297
+ const em = mockEm()
298
+ const repo = new AiChatConversationRepository(em)
299
+ const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
300
+ await repo.createOrGet({ conversationId: 'c1', agentId: 'a' }, ctx)
301
+
302
+ const first = await repo.appendMessage(
303
+ 'c1',
304
+ { role: 'user', content: 'Hello', clientMessageId: 'm-1' },
305
+ ctx,
306
+ )
307
+ const second = await repo.appendMessage(
308
+ 'c1',
309
+ { role: 'user', content: 'Hello (retry)', clientMessageId: 'm-1' },
310
+ ctx,
311
+ )
312
+ expect(second.id).toBe(first.id)
313
+ expect(em.__stores.message).toHaveLength(1)
314
+ expect(em.__stores.message[0].content).toBe('Hello')
315
+ })
316
+
317
+ it('appendMessage updates the conversation `lastMessageAt`', async () => {
318
+ const em = mockEm()
319
+ const repo = new AiChatConversationRepository(em)
320
+ const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
321
+ await repo.createOrGet({ conversationId: 'c1', agentId: 'a' }, ctx)
322
+ const at = new Date('2026-05-18T12:00:00.000Z')
323
+ await repo.appendMessage(
324
+ 'c1',
325
+ { role: 'user', content: 'Hi', clientMessageId: 'm-1' },
326
+ ctx,
327
+ { createdAt: at },
328
+ )
329
+ expect(em.__stores.conv[0].lastMessageAt).toEqual(at)
330
+ })
331
+
332
+ it('appendMessage refuses to write into another user\'s conversation', async () => {
333
+ const em = mockEm()
334
+ const repo = new AiChatConversationRepository(em)
335
+ await repo.createOrGet(
336
+ { conversationId: 'c-owned', agentId: 'a' },
337
+ { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' },
338
+ )
339
+ await expect(
340
+ repo.appendMessage(
341
+ 'c-owned',
342
+ { role: 'user', content: 'evil' },
343
+ { tenantId: tenantAlpha, organizationId: null, userId: 'u-2' },
344
+ ),
345
+ ).rejects.toBeInstanceOf(AiChatConversationAccessError)
346
+ })
347
+
348
+ it('softDelete marks the conversation and all messages as deleted in one transaction', async () => {
349
+ const em = mockEm()
350
+ const repo = new AiChatConversationRepository(em)
351
+ const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
352
+ await repo.createOrGet({ conversationId: 'c1', agentId: 'a' }, ctx)
353
+ await repo.appendMessage(
354
+ 'c1',
355
+ { role: 'user', content: 'a', clientMessageId: 'm-a' },
356
+ ctx,
357
+ )
358
+ await repo.appendMessage(
359
+ 'c1',
360
+ { role: 'assistant', content: 'b', clientMessageId: 'm-b' },
361
+ ctx,
362
+ )
363
+ const at = new Date('2026-05-18T13:00:00.000Z')
364
+ await repo.softDelete('c1', ctx, at)
365
+ expect(em.__stores.conv[0].deletedAt).toEqual(at)
366
+ expect(em.__stores.conv[0].status).toBe('closed')
367
+ expect(em.__stores.message.every((row: MessageRow) => row.deletedAt instanceof Date)).toBe(true)
368
+ })
369
+
370
+ it('list only returns conversations owned by the caller', async () => {
371
+ const em = mockEm()
372
+ const repo = new AiChatConversationRepository(em)
373
+ const u1 = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
374
+ const u2 = { tenantId: tenantAlpha, organizationId: null, userId: 'u-2' }
375
+ await repo.createOrGet({ conversationId: 'a', agentId: 'x' }, u1)
376
+ await repo.createOrGet({ conversationId: 'b', agentId: 'x' }, u2)
377
+ const result = await repo.list(u1, { agentId: 'x' })
378
+ expect(result.items).toHaveLength(1)
379
+ expect(result.items[0].conversationId).toBe('a')
380
+ })
381
+
382
+ it('list returns same-tenant conversations across owners for conversation managers only', async () => {
383
+ const em = mockEm()
384
+ const repo = new AiChatConversationRepository(em)
385
+ const u1 = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
386
+ const u2 = { tenantId: tenantAlpha, organizationId: null, userId: 'u-2' }
387
+ await repo.createOrGet({ conversationId: 'alpha-owned', agentId: 'x' }, u1)
388
+ await repo.createOrGet({ conversationId: 'alpha-other', agentId: 'x' }, u2)
389
+ await repo.createOrGet(
390
+ { conversationId: 'beta-other', agentId: 'x' },
391
+ { tenantId: tenantBeta, organizationId: null, userId: 'u-3' },
392
+ )
393
+
394
+ const viewOnly = await repo.list(u1, { agentId: 'x' })
395
+ expect(viewOnly.items.map((row) => row.conversationId)).toEqual(['alpha-owned'])
396
+
397
+ const manager = await repo.list(
398
+ { ...u1, canManageConversations: true },
399
+ { agentId: 'x' },
400
+ )
401
+ expect(manager.items.map((row) => row.conversationId).sort()).toEqual([
402
+ 'alpha-other',
403
+ 'alpha-owned',
404
+ ])
405
+ })
406
+
407
+ it('list filters by agentId when supplied', async () => {
408
+ const em = mockEm()
409
+ const repo = new AiChatConversationRepository(em)
410
+ const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
411
+ await repo.createOrGet({ conversationId: 'a', agentId: 'agent-a' }, ctx)
412
+ await repo.createOrGet({ conversationId: 'b', agentId: 'agent-b' }, ctx)
413
+ const result = await repo.list(ctx, { agentId: 'agent-b' })
414
+ expect(result.items).toHaveLength(1)
415
+ expect(result.items[0].agentId).toBe('agent-b')
416
+ })
417
+
418
+ it('importLocalConversation imports unique messages and skips duplicates idempotently', async () => {
419
+ const em = mockEm()
420
+ const repo = new AiChatConversationRepository(em)
421
+ const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
422
+ const payload = {
423
+ conversation: { conversationId: 'imp-1', agentId: 'x', title: 'Imported' },
424
+ messages: [
425
+ { role: 'user' as const, content: 'hi', clientMessageId: 'm-1' },
426
+ { role: 'assistant' as const, content: 'hello', clientMessageId: 'm-2' },
427
+ ],
428
+ }
429
+ const first = await repo.importLocalConversation(payload, ctx)
430
+ expect(first.importedMessageCount).toBe(2)
431
+ expect(first.skippedMessageCount).toBe(0)
432
+
433
+ const second = await repo.importLocalConversation(payload, ctx)
434
+ expect(second.importedMessageCount).toBe(0)
435
+ expect(second.skippedMessageCount).toBe(2)
436
+ expect(em.__stores.message).toHaveLength(2)
437
+ expect(em.__stores.conv[0].importedFromLocalAt).not.toBeNull()
438
+ })
439
+
440
+ it('importLocalConversation skips messages without a clientMessageId (no dedupe key)', async () => {
441
+ const em = mockEm()
442
+ const repo = new AiChatConversationRepository(em)
443
+ const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
444
+ const result = await repo.importLocalConversation(
445
+ {
446
+ conversation: { conversationId: 'imp-2', agentId: 'x' },
447
+ messages: [{ role: 'user', content: 'no-id' }],
448
+ },
449
+ ctx,
450
+ )
451
+ expect(result.importedMessageCount).toBe(0)
452
+ expect(result.skippedMessageCount).toBe(1)
453
+ expect(em.__stores.message).toHaveLength(0)
454
+ })
455
+
456
+ it('getTranscript returns messages ascending and emits a usable forward-pagination cursor', async () => {
457
+ const em = mockEm()
458
+ const repo = new AiChatConversationRepository(em)
459
+ const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
460
+ await repo.createOrGet({ conversationId: 'paged', agentId: 'a' }, ctx)
461
+ for (let i = 0; i < 5; i += 1) {
462
+ await repo.appendMessage(
463
+ 'paged',
464
+ { role: 'user', content: `m${i}`, clientMessageId: `cm-${i}` },
465
+ ctx,
466
+ { createdAt: new Date(`2026-05-18T12:0${i}:00.000Z`) },
467
+ )
468
+ }
469
+
470
+ const firstPage = await repo.getTranscript('paged', ctx, { limit: 2 })
471
+ expect(firstPage).not.toBeNull()
472
+ expect(firstPage!.messages.map((m) => m.content)).toEqual(['m3', 'm4'])
473
+ expect(firstPage!.nextCursor).toBe(new Date('2026-05-18T12:03:00.000Z').toISOString())
474
+
475
+ const secondPage = await repo.getTranscript('paged', ctx, {
476
+ limit: 2,
477
+ before: firstPage!.nextCursor!,
478
+ })
479
+ expect(secondPage).not.toBeNull()
480
+ expect(secondPage!.messages.map((m) => m.content)).toEqual(['m1', 'm2'])
481
+ expect(secondPage!.nextCursor).toBe(new Date('2026-05-18T12:01:00.000Z').toISOString())
482
+
483
+ const thirdPage = await repo.getTranscript('paged', ctx, {
484
+ limit: 2,
485
+ before: secondPage!.nextCursor!,
486
+ })
487
+ expect(thirdPage).not.toBeNull()
488
+ expect(thirdPage!.messages.map((m) => m.content)).toEqual(['m0'])
489
+ expect(thirdPage!.nextCursor).toBeNull()
490
+ })
491
+
492
+ it('getTranscript refuses to leak a transcript to a non-owner', async () => {
493
+ const em = mockEm()
494
+ const repo = new AiChatConversationRepository(em)
495
+ const owner = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
496
+ const intruder = { tenantId: tenantAlpha, organizationId: null, userId: 'u-2' }
497
+ await repo.createOrGet({ conversationId: 'private', agentId: 'a' }, owner)
498
+ await repo.appendMessage(
499
+ 'private',
500
+ { role: 'user', content: 'secret', clientMessageId: 'cm-1' },
501
+ owner,
502
+ )
503
+ const leak = await repo.getTranscript('private', intruder)
504
+ expect(leak).toBeNull()
505
+ })
506
+
507
+ it('getTranscript allows a conversation manager to load another user transcript in the same tenant only', async () => {
508
+ const em = mockEm()
509
+ const repo = new AiChatConversationRepository(em)
510
+ const owner = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
511
+ const manager = {
512
+ tenantId: tenantAlpha,
513
+ organizationId: null,
514
+ userId: 'u-2',
515
+ canManageConversations: true,
516
+ }
517
+ await repo.createOrGet({ conversationId: 'managed', agentId: 'a' }, owner)
518
+ await repo.appendMessage(
519
+ 'managed',
520
+ { role: 'user', content: 'same-tenant secret', clientMessageId: 'cm-1' },
521
+ owner,
522
+ )
523
+
524
+ const transcript = await repo.getTranscript('managed', manager)
525
+ expect(transcript?.messages.map((message) => message.content)).toEqual([
526
+ 'same-tenant secret',
527
+ ])
528
+
529
+ const crossTenant = await repo.getTranscript('managed', {
530
+ tenantId: tenantBeta,
531
+ organizationId: null,
532
+ userId: 'u-2',
533
+ canManageConversations: true,
534
+ })
535
+ expect(crossTenant).toBeNull()
536
+ })
537
+
538
+ it('update refuses to touch a conversation owned by another user', async () => {
539
+ const em = mockEm()
540
+ const repo = new AiChatConversationRepository(em)
541
+ await repo.createOrGet(
542
+ { conversationId: 'c-owned', agentId: 'a' },
543
+ { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' },
544
+ )
545
+ await expect(
546
+ repo.update(
547
+ 'c-owned',
548
+ { title: 'hijacked' },
549
+ { tenantId: tenantAlpha, organizationId: null, userId: 'u-2' },
550
+ ),
551
+ ).rejects.toBeInstanceOf(AiChatConversationAccessError)
552
+ })
553
+
554
+ it('softDelete lets a conversation manager delete another user conversation in the same tenant only', async () => {
555
+ const em = mockEm()
556
+ const repo = new AiChatConversationRepository(em)
557
+ await repo.createOrGet(
558
+ { conversationId: 'same-tenant', agentId: 'a' },
559
+ { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' },
560
+ )
561
+ await repo.createOrGet(
562
+ { conversationId: 'other-tenant', agentId: 'a' },
563
+ { tenantId: tenantBeta, organizationId: null, userId: 'u-3' },
564
+ )
565
+ const at = new Date('2026-05-18T14:00:00.000Z')
566
+ await repo.softDelete(
567
+ 'same-tenant',
568
+ {
569
+ tenantId: tenantAlpha,
570
+ organizationId: null,
571
+ userId: 'u-2',
572
+ canManageConversations: true,
573
+ },
574
+ at,
575
+ )
576
+ expect(
577
+ em.__stores.conv.find((row: ConvRow) => row.conversationId === 'same-tenant')?.deletedAt,
578
+ ).toEqual(at)
579
+
580
+ await expect(
581
+ repo.softDelete('other-tenant', {
582
+ tenantId: tenantAlpha,
583
+ organizationId: null,
584
+ userId: 'u-2',
585
+ canManageConversations: true,
586
+ }),
587
+ ).rejects.toBeInstanceOf(AiChatConversationAccessError)
588
+ expect(
589
+ em.__stores.conv.find((row: ConvRow) => row.conversationId === 'other-tenant')?.deletedAt,
590
+ ).toBeNull()
591
+ })
592
+ })