@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
|
@@ -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
|