@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,597 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import {
3
+ findOneWithDecryption,
4
+ findWithDecryption,
5
+ } from '@open-mercato/shared/lib/encryption/find'
6
+ import {
7
+ AiChatConversation,
8
+ AiChatConversationParticipant,
9
+ AiChatMessage,
10
+ } from '../entities'
11
+ import type {
12
+ AiChatMessageAppendInput,
13
+ AiChatPageContextInput,
14
+ } from '../validators'
15
+
16
+ /**
17
+ * Persistent store for AI chat conversations, participants, and messages.
18
+ *
19
+ * Owner-first MVP per spec
20
+ * `2026-05-05-ai-chat-server-side-conversation-storage`. Every read/write
21
+ * goes through `findOneWithDecryption` / `findWithDecryption` so the repo
22
+ * stays consistent with the rest of the module and is GDPR-encryption-ready
23
+ * without a second refactor when `content` / `ui_parts` columns are
24
+ * eventually flagged.
25
+ *
26
+ * Tenant + organization scope is required on every method. View-only callers
27
+ * are owner-scoped. Callers with `ai_assistant.conversations.manage` may
28
+ * list/read/update/delete any conversation in the same tenant/org, but never
29
+ * outside that boundary. The participant row is written transactionally
30
+ * alongside conversation create/import.
31
+ *
32
+ * TODO(ai-chat-sharing): widen the non-manage read predicate to include
33
+ * explicit undeleted participants once shared conversations are implemented.
34
+ */
35
+
36
+ export interface AiChatConversationContext {
37
+ tenantId: string
38
+ organizationId?: string | null
39
+ userId: string
40
+ canManageConversations?: boolean
41
+ }
42
+
43
+ export interface AiChatConversationCreateOrGetInput {
44
+ conversationId?: string | null
45
+ agentId: string
46
+ title?: string | null
47
+ pageContext?: AiChatPageContextInput | null
48
+ /** Marks the conversation as imported from local storage (sets `importedFromLocalAt`). */
49
+ importedFromLocal?: boolean
50
+ /** Optional explicit `now` for deterministic tests. */
51
+ now?: Date
52
+ }
53
+
54
+ export interface AiChatConversationListOptions {
55
+ agentId?: string | null
56
+ status?: 'open' | 'closed' | null
57
+ limit?: number
58
+ cursor?: string | null
59
+ }
60
+
61
+ export interface AiChatConversationUpdateInput {
62
+ title?: string | null
63
+ status?: 'open' | 'closed'
64
+ pageContext?: AiChatPageContextInput | null
65
+ /** Optional explicit `now` for deterministic tests. */
66
+ now?: Date
67
+ }
68
+
69
+ export interface AiChatTranscriptOptions {
70
+ limit?: number
71
+ /** ISO timestamp string; rows strictly older than this are returned. */
72
+ before?: string | null
73
+ }
74
+
75
+ export interface AiChatTranscriptResult {
76
+ conversation: AiChatConversation
77
+ messages: AiChatMessage[]
78
+ nextCursor: string | null
79
+ }
80
+
81
+ export interface AiChatMessageAppendOptions {
82
+ /** Override the message timestamp (used to thread server-injected stream-completion turns). */
83
+ createdAt?: Date
84
+ /** Override `createdByUserId` (defaults to the calling context user). */
85
+ createdByUserId?: string | null
86
+ }
87
+
88
+ export interface AiChatConversationImportResult {
89
+ conversation: AiChatConversation
90
+ importedMessageCount: number
91
+ skippedMessageCount: number
92
+ }
93
+
94
+ const DEFAULT_LIST_LIMIT = 50
95
+ const MAX_LIST_LIMIT = 100
96
+ const DEFAULT_TRANSCRIPT_LIMIT = 100
97
+ const MAX_TRANSCRIPT_LIMIT = 200
98
+
99
+ export class AiChatConversationAccessError extends Error {
100
+ override readonly name = 'AiChatConversationAccessError'
101
+ constructor(message: string = 'Conversation is not accessible to the caller.') {
102
+ super(message)
103
+ }
104
+ }
105
+
106
+ export class AiChatConversationRepository {
107
+ constructor(private readonly em: EntityManager) {}
108
+
109
+ /**
110
+ * Idempotent create. If a non-deleted conversation already exists for the
111
+ * caller in this tenant/org with the same `conversationId`, returns the
112
+ * existing row. The owner-participant row is created in the same
113
+ * transaction; a partial failure leaves no orphan conversation.
114
+ */
115
+ async createOrGet(
116
+ input: AiChatConversationCreateOrGetInput,
117
+ ctx: AiChatConversationContext,
118
+ ): Promise<AiChatConversation> {
119
+ assertContext(ctx, 'createOrGet')
120
+ if (!input?.agentId) {
121
+ throw new Error('AiChatConversationRepository.createOrGet requires agentId')
122
+ }
123
+ const now = input.now ?? new Date()
124
+ const conversationId = (input.conversationId ?? '').trim() || generateConversationId()
125
+
126
+ return this.em.transactional(async (tx) => {
127
+ const existing = await findOneAccessibleConversation(
128
+ tx as unknown as EntityManager,
129
+ conversationId,
130
+ ctx,
131
+ )
132
+ if (existing) {
133
+ if (existing.ownerUserId !== ctx.userId) {
134
+ throw new AiChatConversationAccessError()
135
+ }
136
+ return existing
137
+ }
138
+ const conversation = tx.create(AiChatConversation, {
139
+ tenantId: ctx.tenantId,
140
+ organizationId: ctx.organizationId ?? null,
141
+ conversationId,
142
+ agentId: input.agentId,
143
+ ownerUserId: ctx.userId,
144
+ title: normalizeTitle(input.title),
145
+ status: 'open',
146
+ visibility: 'private',
147
+ pageContext: input.pageContext ?? null,
148
+ lastMessageAt: null,
149
+ importedFromLocalAt: input.importedFromLocal ? now : null,
150
+ createdAt: now,
151
+ updatedAt: now,
152
+ deletedAt: null,
153
+ } as unknown as AiChatConversation)
154
+ const participant = tx.create(AiChatConversationParticipant, {
155
+ tenantId: ctx.tenantId,
156
+ organizationId: ctx.organizationId ?? null,
157
+ conversationId,
158
+ userId: ctx.userId,
159
+ role: 'owner',
160
+ lastReadAt: null,
161
+ createdAt: now,
162
+ updatedAt: now,
163
+ } as unknown as AiChatConversationParticipant)
164
+ await tx.persist(conversation).persist(participant).flush()
165
+ return conversation
166
+ })
167
+ }
168
+
169
+ /** Fetch within tenant/org. View-only callers see only their own conversations. */
170
+ async getById(
171
+ conversationId: string,
172
+ ctx: AiChatConversationContext,
173
+ ): Promise<AiChatConversation | null> {
174
+ assertContext(ctx, 'getById')
175
+ if (!conversationId) return null
176
+ const row = await findOneAccessibleConversation(this.em, conversationId, ctx)
177
+ if (!row) return null
178
+ if (!canAccessConversation(row, ctx)) return null
179
+ return row
180
+ }
181
+
182
+ /** Owner-scoped list unless the caller has tenant/org manage access. */
183
+ async list(
184
+ ctx: AiChatConversationContext,
185
+ options: AiChatConversationListOptions = {},
186
+ ): Promise<{ items: AiChatConversation[]; nextCursor: string | null }> {
187
+ assertContext(ctx, 'list')
188
+ const limit = clampLimit(options.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT)
189
+ const where: Record<string, unknown> = {
190
+ tenantId: ctx.tenantId,
191
+ organizationId: ctx.organizationId ?? null,
192
+ deletedAt: null,
193
+ }
194
+ if (!canManageConversations(ctx)) where.ownerUserId = ctx.userId
195
+ if (options.agentId) where.agentId = options.agentId
196
+ if (options.status) where.status = options.status
197
+ if (options.cursor) {
198
+ const cursorDate = parseIso(options.cursor)
199
+ if (cursorDate) {
200
+ where.lastMessageAt = { $lt: cursorDate }
201
+ }
202
+ }
203
+ const rows = await findWithDecryption<AiChatConversation>(
204
+ this.em,
205
+ AiChatConversation,
206
+ where as any,
207
+ {
208
+ orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }] as any,
209
+ limit: limit + 1,
210
+ },
211
+ {
212
+ tenantId: ctx.tenantId ?? null,
213
+ organizationId: ctx.organizationId ?? null,
214
+ },
215
+ )
216
+ let nextCursor: string | null = null
217
+ if (rows.length > limit) {
218
+ const lastIncluded = rows[limit - 1]
219
+ const cursorValue = lastIncluded.lastMessageAt ?? lastIncluded.createdAt
220
+ nextCursor = cursorValue ? cursorValue.toISOString() : null
221
+ }
222
+ return { items: rows.slice(0, limit), nextCursor }
223
+ }
224
+
225
+ /** Update within tenant/org. View-only callers can update only their own conversations. */
226
+ async update(
227
+ conversationId: string,
228
+ patch: AiChatConversationUpdateInput,
229
+ ctx: AiChatConversationContext,
230
+ ): Promise<AiChatConversation> {
231
+ assertContext(ctx, 'update')
232
+ if (!conversationId) {
233
+ throw new Error('AiChatConversationRepository.update requires conversationId')
234
+ }
235
+ return this.em.transactional(async (tx) => {
236
+ const existing = await findOneAccessibleConversation(
237
+ tx as unknown as EntityManager,
238
+ conversationId,
239
+ ctx,
240
+ )
241
+ if (!existing) {
242
+ throw new AiChatConversationAccessError(
243
+ `Conversation "${conversationId}" was not found for the caller.`,
244
+ )
245
+ }
246
+ if (!canAccessConversation(existing, ctx)) {
247
+ throw new AiChatConversationAccessError()
248
+ }
249
+ const now = patch.now ?? new Date()
250
+ if (Object.prototype.hasOwnProperty.call(patch, 'title')) {
251
+ existing.title = normalizeTitle(patch.title)
252
+ }
253
+ if (patch.status) existing.status = patch.status
254
+ if (Object.prototype.hasOwnProperty.call(patch, 'pageContext')) {
255
+ existing.pageContext = patch.pageContext ?? null
256
+ }
257
+ existing.updatedAt = now
258
+ await tx.persist(existing).flush()
259
+ return existing
260
+ })
261
+ }
262
+
263
+ /** Soft-delete the conversation and all its messages in one transaction. */
264
+ async softDelete(
265
+ conversationId: string,
266
+ ctx: AiChatConversationContext,
267
+ now: Date = new Date(),
268
+ ): Promise<void> {
269
+ assertContext(ctx, 'softDelete')
270
+ if (!conversationId) {
271
+ throw new Error('AiChatConversationRepository.softDelete requires conversationId')
272
+ }
273
+ await this.em.transactional(async (tx) => {
274
+ const existing = await findOneAccessibleConversation(
275
+ tx as unknown as EntityManager,
276
+ conversationId,
277
+ ctx,
278
+ )
279
+ if (!existing) {
280
+ throw new AiChatConversationAccessError(
281
+ `Conversation "${conversationId}" was not found for the caller.`,
282
+ )
283
+ }
284
+ if (!canAccessConversation(existing, ctx)) {
285
+ throw new AiChatConversationAccessError()
286
+ }
287
+ existing.deletedAt = now
288
+ existing.status = 'closed'
289
+ existing.updatedAt = now
290
+ await tx.persist(existing).flush()
291
+
292
+ const messages = await findWithDecryption<AiChatMessage>(
293
+ tx as unknown as EntityManager,
294
+ AiChatMessage,
295
+ {
296
+ tenantId: ctx.tenantId,
297
+ organizationId: ctx.organizationId ?? null,
298
+ conversationId,
299
+ deletedAt: null,
300
+ } as any,
301
+ {},
302
+ {
303
+ tenantId: ctx.tenantId ?? null,
304
+ organizationId: ctx.organizationId ?? null,
305
+ },
306
+ )
307
+ for (const msg of messages) {
308
+ msg.deletedAt = now
309
+ msg.updatedAt = now
310
+ tx.persist(msg)
311
+ }
312
+ if (messages.length > 0) await tx.flush()
313
+ })
314
+ }
315
+
316
+ /**
317
+ * Owner-only transcript hydration. Internally fetched DESC so the `before`
318
+ * cursor naturally advances toward older messages, then reversed so the
319
+ * response contract (`messages` array ordered ascending by `createdAt`)
320
+ * stays stable for callers. `nextCursor` points to the OLDEST message in
321
+ * the returned page — the next call with `before=<cursor>` fetches the
322
+ * next-older window.
323
+ */
324
+ async getTranscript(
325
+ conversationId: string,
326
+ ctx: AiChatConversationContext,
327
+ options: AiChatTranscriptOptions = {},
328
+ ): Promise<AiChatTranscriptResult | null> {
329
+ assertContext(ctx, 'getTranscript')
330
+ if (!conversationId) return null
331
+ const conversation = await this.getById(conversationId, ctx)
332
+ if (!conversation) return null
333
+ const limit = clampLimit(options.limit, DEFAULT_TRANSCRIPT_LIMIT, MAX_TRANSCRIPT_LIMIT)
334
+ const where: Record<string, unknown> = {
335
+ tenantId: ctx.tenantId,
336
+ organizationId: ctx.organizationId ?? null,
337
+ conversationId,
338
+ deletedAt: null,
339
+ }
340
+ if (options.before) {
341
+ const beforeDate = parseIso(options.before)
342
+ if (beforeDate) {
343
+ where.createdAt = { $lt: beforeDate }
344
+ }
345
+ }
346
+ const rows = await findWithDecryption<AiChatMessage>(
347
+ this.em,
348
+ AiChatMessage,
349
+ where as any,
350
+ {
351
+ orderBy: { createdAt: 'desc' } as any,
352
+ limit: limit + 1,
353
+ },
354
+ {
355
+ tenantId: ctx.tenantId ?? null,
356
+ organizationId: ctx.organizationId ?? null,
357
+ },
358
+ )
359
+ let nextCursor: string | null = null
360
+ let pageDesc: AiChatMessage[]
361
+ if (rows.length > limit) {
362
+ pageDesc = rows.slice(0, limit)
363
+ const oldestIncluded = pageDesc[pageDesc.length - 1]
364
+ nextCursor = oldestIncluded?.createdAt ? oldestIncluded.createdAt.toISOString() : null
365
+ } else {
366
+ pageDesc = rows
367
+ }
368
+ const messages = [...pageDesc].reverse()
369
+ return { conversation, messages, nextCursor }
370
+ }
371
+
372
+ /**
373
+ * Append a single message to an owner-accessible conversation. Honors
374
+ * `clientMessageId` idempotency: if a non-deleted message with the same
375
+ * client id already exists, returns it untouched.
376
+ */
377
+ async appendMessage(
378
+ conversationId: string,
379
+ input: AiChatMessageAppendInput,
380
+ ctx: AiChatConversationContext,
381
+ options: AiChatMessageAppendOptions = {},
382
+ ): Promise<AiChatMessage> {
383
+ assertContext(ctx, 'appendMessage')
384
+ if (!conversationId) {
385
+ throw new Error('AiChatConversationRepository.appendMessage requires conversationId')
386
+ }
387
+ return this.em.transactional(async (tx) => {
388
+ const conversation = await findOneAccessibleConversation(
389
+ tx as unknown as EntityManager,
390
+ conversationId,
391
+ ctx,
392
+ )
393
+ if (!conversation) {
394
+ throw new AiChatConversationAccessError(
395
+ `Conversation "${conversationId}" was not found for the caller.`,
396
+ )
397
+ }
398
+ if (conversation.ownerUserId !== ctx.userId) {
399
+ throw new AiChatConversationAccessError()
400
+ }
401
+ const now = options.createdAt ?? new Date()
402
+ if (input.clientMessageId) {
403
+ const existing = await findOneWithDecryption<AiChatMessage>(
404
+ tx as unknown as EntityManager,
405
+ AiChatMessage,
406
+ {
407
+ tenantId: ctx.tenantId,
408
+ organizationId: ctx.organizationId ?? null,
409
+ conversationId,
410
+ clientMessageId: input.clientMessageId,
411
+ deletedAt: null,
412
+ } as any,
413
+ {},
414
+ {
415
+ tenantId: ctx.tenantId ?? null,
416
+ organizationId: ctx.organizationId ?? null,
417
+ },
418
+ )
419
+ if (existing) return existing
420
+ }
421
+ const message = tx.create(AiChatMessage, {
422
+ tenantId: ctx.tenantId,
423
+ organizationId: ctx.organizationId ?? null,
424
+ conversationId,
425
+ clientMessageId: input.clientMessageId ?? null,
426
+ role: input.role,
427
+ content: input.content,
428
+ uiParts: normalizeArray(input.uiParts),
429
+ attachmentIds: normalizeArray(input.attachmentIds),
430
+ filesMetadata: normalizeArray(input.files),
431
+ model: input.model ?? null,
432
+ metadata: input.metadata ?? null,
433
+ createdByUserId:
434
+ options.createdByUserId === undefined
435
+ ? input.role === 'user'
436
+ ? ctx.userId
437
+ : null
438
+ : options.createdByUserId,
439
+ createdAt: now,
440
+ updatedAt: now,
441
+ deletedAt: null,
442
+ } as unknown as AiChatMessage)
443
+ conversation.lastMessageAt = now
444
+ conversation.updatedAt = now
445
+ await tx.persist(message).persist(conversation).flush()
446
+ return message
447
+ })
448
+ }
449
+
450
+ /**
451
+ * Lazy migration entrypoint: create-or-get the conversation and append the
452
+ * provided messages with `clientMessageId` dedupe. Designed to be safe to
453
+ * call repeatedly — repeated imports of the same payload return the same
454
+ * counts of imported/skipped rows.
455
+ */
456
+ async importLocalConversation(
457
+ input: {
458
+ conversation: AiChatConversationCreateOrGetInput & {
459
+ status?: 'open' | 'closed'
460
+ }
461
+ messages: AiChatMessageAppendInput[]
462
+ },
463
+ ctx: AiChatConversationContext,
464
+ now: Date = new Date(),
465
+ ): Promise<AiChatConversationImportResult> {
466
+ assertContext(ctx, 'importLocalConversation')
467
+ const conversation = await this.createOrGet(
468
+ { ...input.conversation, importedFromLocal: true, now },
469
+ ctx,
470
+ )
471
+ if (input.conversation.status && conversation.status !== input.conversation.status) {
472
+ await this.update(
473
+ conversation.conversationId,
474
+ { status: input.conversation.status, now },
475
+ ctx,
476
+ )
477
+ }
478
+ let imported = 0
479
+ let skipped = 0
480
+ for (const message of input.messages) {
481
+ if (!message.clientMessageId) {
482
+ // Without an idempotency key the import has no safe way to dedupe.
483
+ skipped += 1
484
+ continue
485
+ }
486
+ const before = await findOneWithDecryption<AiChatMessage>(
487
+ this.em,
488
+ AiChatMessage,
489
+ {
490
+ tenantId: ctx.tenantId,
491
+ organizationId: ctx.organizationId ?? null,
492
+ conversationId: conversation.conversationId,
493
+ clientMessageId: message.clientMessageId,
494
+ deletedAt: null,
495
+ } as any,
496
+ {},
497
+ {
498
+ tenantId: ctx.tenantId ?? null,
499
+ organizationId: ctx.organizationId ?? null,
500
+ },
501
+ )
502
+ if (before) {
503
+ skipped += 1
504
+ continue
505
+ }
506
+ await this.appendMessage(
507
+ conversation.conversationId,
508
+ message,
509
+ ctx,
510
+ { createdAt: now },
511
+ )
512
+ imported += 1
513
+ }
514
+ return {
515
+ conversation,
516
+ importedMessageCount: imported,
517
+ skippedMessageCount: skipped,
518
+ }
519
+ }
520
+ }
521
+
522
+ function assertContext(ctx: AiChatConversationContext | undefined, method: string): void {
523
+ if (!ctx?.tenantId) {
524
+ throw new Error(`AiChatConversationRepository.${method} requires tenantId`)
525
+ }
526
+ if (!ctx?.userId) {
527
+ throw new Error(`AiChatConversationRepository.${method} requires userId`)
528
+ }
529
+ }
530
+
531
+ function canManageConversations(ctx: AiChatConversationContext): boolean {
532
+ return ctx.canManageConversations === true
533
+ }
534
+
535
+ function canAccessConversation(
536
+ row: AiChatConversation,
537
+ ctx: AiChatConversationContext,
538
+ ): boolean {
539
+ return canManageConversations(ctx) || row.ownerUserId === ctx.userId
540
+ }
541
+
542
+ async function findOneAccessibleConversation(
543
+ em: EntityManager,
544
+ conversationId: string,
545
+ ctx: AiChatConversationContext,
546
+ ): Promise<AiChatConversation | null> {
547
+ const row = await findOneWithDecryption<AiChatConversation>(
548
+ em,
549
+ AiChatConversation,
550
+ {
551
+ tenantId: ctx.tenantId,
552
+ organizationId: ctx.organizationId ?? null,
553
+ conversationId,
554
+ deletedAt: null,
555
+ } as any,
556
+ {},
557
+ {
558
+ tenantId: ctx.tenantId ?? null,
559
+ organizationId: ctx.organizationId ?? null,
560
+ },
561
+ )
562
+ return row ?? null
563
+ }
564
+
565
+ function normalizeTitle(title: string | null | undefined): string | null {
566
+ if (title === undefined) return null
567
+ if (title === null) return null
568
+ const trimmed = title.trim()
569
+ return trimmed.length > 0 ? trimmed : null
570
+ }
571
+
572
+ function normalizeArray<T>(value: T[] | null | undefined): T[] | null {
573
+ if (!Array.isArray(value) || value.length === 0) return null
574
+ return value
575
+ }
576
+
577
+ function clampLimit(value: number | undefined | null, fallback: number, max: number): number {
578
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
579
+ return Math.max(1, Math.min(Math.floor(value), max))
580
+ }
581
+
582
+ function parseIso(value: string): Date | null {
583
+ if (!value) return null
584
+ const date = new Date(value)
585
+ return Number.isNaN(date.getTime()) ? null : date
586
+ }
587
+
588
+ function generateConversationId(): string {
589
+ // Prefer the runtime crypto generator when present; fall back to a non-cryptographic
590
+ // string for environments without `crypto.randomUUID()` (older Node / test mocks).
591
+ const cryptoMod: { randomUUID?: () => string } | undefined =
592
+ typeof globalThis === 'object' ? (globalThis as any).crypto : undefined
593
+ if (cryptoMod?.randomUUID) return cryptoMod.randomUUID()
594
+ return `chat_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`
595
+ }
596
+
597
+ export default AiChatConversationRepository