@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -2
- 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 +197 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/route.js +272 -0
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/conversations/import/route.js +108 -0
- package/dist/modules/ai_assistant/api/ai/conversations/import/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/conversations/route.js +207 -0
- package/dist/modules/ai_assistant/api/ai/conversations/route.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities/AiChatConversation.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiChatConversation.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities/AiChatConversationParticipant.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiChatConversationParticipant.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities/AiChatMessage.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiChatMessage.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities.js +200 -0
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js +448 -0
- package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js.map +7 -0
- package/dist/modules/ai_assistant/data/validators.js +72 -0
- package/dist/modules/ai_assistant/data/validators.js.map +7 -0
- package/dist/modules/ai_assistant/i18n/de.json +3 -0
- package/dist/modules/ai_assistant/i18n/en.json +3 -0
- package/dist/modules/ai_assistant/i18n/es.json +3 -0
- package/dist/modules/ai_assistant/i18n/pl.json +3 -0
- package/dist/modules/ai_assistant/lib/conversation-storage.js +43 -0
- package/dist/modules/ai_assistant/lib/conversation-storage.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260518092853_ai_assistant.js +28 -0
- package/dist/modules/ai_assistant/migrations/Migration20260518092853_ai_assistant.js.map +7 -0
- package/dist/modules/ai_assistant/setup.js +1 -0
- package/dist/modules/ai_assistant/setup.js.map +2 -2
- package/generated/entities/ai_chat_conversation/index.ts +15 -0
- package/generated/entities/ai_chat_conversation_participant/index.ts +9 -0
- package/generated/entities/ai_chat_message/index.ts +16 -0
- package/generated/entities.ids.generated.ts +4 -1
- package/generated/entity-fields-registry.ts +46 -0
- package/jest.config.cjs +3 -1
- package/package.json +14 -15
- package/src/modules/ai_assistant/acl.ts +1 -0
- package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +107 -0
- package/src/modules/ai_assistant/api/ai/chat/route.ts +245 -1
- package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/route.ts +320 -0
- package/src/modules/ai_assistant/api/ai/conversations/__tests__/route.test.ts +93 -0
- package/src/modules/ai_assistant/api/ai/conversations/import/route.ts +122 -0
- package/src/modules/ai_assistant/api/ai/conversations/route.ts +241 -0
- package/src/modules/ai_assistant/data/entities/AiChatConversation.ts +2 -0
- package/src/modules/ai_assistant/data/entities/AiChatConversationParticipant.ts +2 -0
- package/src/modules/ai_assistant/data/entities/AiChatMessage.ts +2 -0
- package/src/modules/ai_assistant/data/entities.ts +255 -0
- package/src/modules/ai_assistant/data/repositories/AiChatConversationRepository.ts +597 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiChatConversationRepository.test.ts +592 -0
- package/src/modules/ai_assistant/data/validators.ts +134 -0
- package/src/modules/ai_assistant/i18n/de.json +3 -0
- package/src/modules/ai_assistant/i18n/en.json +3 -0
- package/src/modules/ai_assistant/i18n/es.json +3 -0
- package/src/modules/ai_assistant/i18n/pl.json +3 -0
- package/src/modules/ai_assistant/lib/conversation-storage.ts +93 -0
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +822 -0
- package/src/modules/ai_assistant/migrations/Migration20260518092853_ai_assistant.ts +39 -0
- package/src/modules/ai_assistant/setup.ts +1 -0
package/src/modules/ai_assistant/data/repositories/__tests__/AiChatConversationRepository.test.ts
ADDED
|
@@ -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
|
+
})
|