@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,122 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from 'next/server'
|
|
2
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
3
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
|
+
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
6
|
+
import { aiChatConversationImportSchema } from '../../../../data/validators'
|
|
7
|
+
import { hasRequiredFeatures } from '../../../../lib/auth'
|
|
8
|
+
import {
|
|
9
|
+
createConversationStorage,
|
|
10
|
+
serializeAiChatConversation,
|
|
11
|
+
} from '../../../../lib/conversation-storage'
|
|
12
|
+
|
|
13
|
+
const REQUIRED_FEATURE = 'ai_assistant.view'
|
|
14
|
+
|
|
15
|
+
export const openApi: OpenApiRouteDoc = {
|
|
16
|
+
tag: 'AI Assistant',
|
|
17
|
+
summary: 'Lazily import a localStorage AI chat conversation',
|
|
18
|
+
methods: {
|
|
19
|
+
POST: {
|
|
20
|
+
operationId: 'aiAssistantImportConversation',
|
|
21
|
+
summary: 'Import a conversation that previously lived only in browser localStorage.',
|
|
22
|
+
description:
|
|
23
|
+
'Idempotent: messages with `clientMessageId` already present in the server transcript are ' +
|
|
24
|
+
'skipped and counted in `skippedMessageCount`. New messages are appended with the original ' +
|
|
25
|
+
'`clientMessageId` so subsequent retries continue to dedupe. Up to 100 messages per request. ' +
|
|
26
|
+
'Attachment previews stored as `data:` URLs in the source localStorage record MUST NOT be ' +
|
|
27
|
+
'forwarded to this endpoint; the UI strips them before upload.',
|
|
28
|
+
responses: [
|
|
29
|
+
{
|
|
30
|
+
status: 200,
|
|
31
|
+
description: 'Import result including imported/skipped counters.',
|
|
32
|
+
mediaType: 'application/json',
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
errors: [
|
|
36
|
+
{ status: 400, description: 'Invalid request body.' },
|
|
37
|
+
{ status: 401, description: 'Unauthenticated caller.' },
|
|
38
|
+
{ status: 403, description: 'Caller lacks the `ai_assistant.view` feature.' },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const metadata = {
|
|
45
|
+
POST: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function jsonError(
|
|
49
|
+
status: number,
|
|
50
|
+
message: string,
|
|
51
|
+
code: string,
|
|
52
|
+
extra?: Record<string, unknown>,
|
|
53
|
+
): NextResponse {
|
|
54
|
+
return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function POST(req: NextRequest): Promise<Response> {
|
|
58
|
+
const auth = await getAuthFromRequest(req)
|
|
59
|
+
if (!auth) return jsonError(401, 'Unauthorized', 'unauthenticated')
|
|
60
|
+
|
|
61
|
+
let rawBody: unknown
|
|
62
|
+
try {
|
|
63
|
+
rawBody = await req.json()
|
|
64
|
+
} catch {
|
|
65
|
+
return jsonError(400, 'Request body must be valid JSON.', 'validation_error')
|
|
66
|
+
}
|
|
67
|
+
const parseResult = aiChatConversationImportSchema.safeParse(rawBody)
|
|
68
|
+
if (!parseResult.success) {
|
|
69
|
+
return jsonError(400, 'Invalid import payload.', 'validation_error', {
|
|
70
|
+
issues: parseResult.error.issues,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const container = await createRequestContainer()
|
|
76
|
+
const rbacService = container.resolve<RbacService>('rbacService')
|
|
77
|
+
const acl = await rbacService.loadAcl(auth.sub, {
|
|
78
|
+
tenantId: auth.tenantId,
|
|
79
|
+
organizationId: auth.orgId,
|
|
80
|
+
})
|
|
81
|
+
if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {
|
|
82
|
+
return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
|
|
83
|
+
}
|
|
84
|
+
if (!auth.tenantId) {
|
|
85
|
+
return jsonError(400, 'Caller is not bound to a tenant.', 'tenant_required')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const repo = createConversationStorage(container)
|
|
89
|
+
const result = await repo.importLocalConversation(
|
|
90
|
+
{
|
|
91
|
+
conversation: {
|
|
92
|
+
conversationId: parseResult.data.conversation.conversationId,
|
|
93
|
+
agentId: parseResult.data.conversation.agentId,
|
|
94
|
+
title: parseResult.data.conversation.title ?? null,
|
|
95
|
+
status: parseResult.data.conversation.status,
|
|
96
|
+
pageContext: parseResult.data.conversation.pageContext ?? null,
|
|
97
|
+
},
|
|
98
|
+
messages: parseResult.data.messages,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
tenantId: auth.tenantId,
|
|
102
|
+
organizationId: auth.orgId ?? null,
|
|
103
|
+
userId: auth.sub,
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
return NextResponse.json({
|
|
107
|
+
conversation: serializeAiChatConversation(result.conversation),
|
|
108
|
+
importedMessageCount: result.importedMessageCount,
|
|
109
|
+
skippedMessageCount: result.skippedMessageCount,
|
|
110
|
+
})
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof Error && error.name === 'AiChatConversationAccessError') {
|
|
113
|
+
return jsonError(404, error.message, 'conversation_not_found')
|
|
114
|
+
}
|
|
115
|
+
console.error('[AI Conversation Import] Failure:', error)
|
|
116
|
+
return jsonError(
|
|
117
|
+
500,
|
|
118
|
+
error instanceof Error ? error.message : 'Failed to import conversation.',
|
|
119
|
+
'internal_error',
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from 'next/server'
|
|
2
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
3
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
|
+
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
6
|
+
import {
|
|
7
|
+
aiChatConversationCreateSchema,
|
|
8
|
+
aiChatConversationListQuerySchema,
|
|
9
|
+
} from '../../../data/validators'
|
|
10
|
+
import { hasRequiredFeatures } from '../../../lib/auth'
|
|
11
|
+
import {
|
|
12
|
+
createConversationStorage,
|
|
13
|
+
serializeAiChatConversation,
|
|
14
|
+
} from '../../../lib/conversation-storage'
|
|
15
|
+
|
|
16
|
+
const REQUIRED_FEATURE = 'ai_assistant.view'
|
|
17
|
+
const MANAGE_CONVERSATIONS_FEATURE = 'ai_assistant.conversations.manage'
|
|
18
|
+
|
|
19
|
+
export const openApi: OpenApiRouteDoc = {
|
|
20
|
+
tag: 'AI Assistant',
|
|
21
|
+
summary: 'Server-side AI chat conversations',
|
|
22
|
+
methods: {
|
|
23
|
+
GET: {
|
|
24
|
+
operationId: 'aiAssistantListConversations',
|
|
25
|
+
summary: 'List AI chat conversations visible to the caller.',
|
|
26
|
+
description:
|
|
27
|
+
'Returns `{ items, nextCursor }` for the authenticated caller, ordered by `lastMessageAt` ' +
|
|
28
|
+
'descending. View-only callers receive only their own conversations. Callers with ' +
|
|
29
|
+
'`ai_assistant.conversations.manage` may list conversations across users in the same ' +
|
|
30
|
+
'tenant/organization. The ' +
|
|
31
|
+
'`agent` and `status` filters are optional; `cursor` is the ISO timestamp returned by a ' +
|
|
32
|
+
'previous response.',
|
|
33
|
+
responses: [
|
|
34
|
+
{
|
|
35
|
+
status: 200,
|
|
36
|
+
description: 'Caller-owned conversation summaries.',
|
|
37
|
+
mediaType: 'application/json',
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
errors: [
|
|
41
|
+
{ status: 400, description: 'Invalid query parameters.' },
|
|
42
|
+
{ status: 401, description: 'Unauthenticated caller.' },
|
|
43
|
+
{ status: 403, description: 'Caller lacks the `ai_assistant.view` feature.' },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
POST: {
|
|
47
|
+
operationId: 'aiAssistantCreateConversation',
|
|
48
|
+
summary: 'Idempotently create a new AI chat conversation.',
|
|
49
|
+
description:
|
|
50
|
+
'If a non-deleted conversation already exists with the supplied `conversationId` for the ' +
|
|
51
|
+
'authenticated caller in this tenant/org, returns the existing summary. Otherwise creates a ' +
|
|
52
|
+
'fresh row and writes the owner-participant row in the same transaction.',
|
|
53
|
+
responses: [
|
|
54
|
+
{
|
|
55
|
+
status: 200,
|
|
56
|
+
description: 'Existing conversation (idempotent path).',
|
|
57
|
+
mediaType: 'application/json',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
status: 201,
|
|
61
|
+
description: 'Newly created conversation.',
|
|
62
|
+
mediaType: 'application/json',
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
errors: [
|
|
66
|
+
{ status: 400, description: 'Invalid request body.' },
|
|
67
|
+
{ status: 401, description: 'Unauthenticated caller.' },
|
|
68
|
+
{ status: 403, description: 'Caller lacks the `ai_assistant.view` feature.' },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const metadata = {
|
|
75
|
+
GET: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
|
|
76
|
+
POST: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function jsonError(
|
|
80
|
+
status: number,
|
|
81
|
+
message: string,
|
|
82
|
+
code: string,
|
|
83
|
+
extra?: Record<string, unknown>,
|
|
84
|
+
): NextResponse {
|
|
85
|
+
return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function loadCallerContext(req: NextRequest): Promise<
|
|
89
|
+
| { kind: 'unauthorized' }
|
|
90
|
+
| { kind: 'forbidden' }
|
|
91
|
+
| { kind: 'missing-tenant' }
|
|
92
|
+
| {
|
|
93
|
+
kind: 'ok'
|
|
94
|
+
tenantId: string
|
|
95
|
+
organizationId: string | null
|
|
96
|
+
userId: string
|
|
97
|
+
canManageConversations: boolean
|
|
98
|
+
}
|
|
99
|
+
> {
|
|
100
|
+
const auth = await getAuthFromRequest(req)
|
|
101
|
+
if (!auth) return { kind: 'unauthorized' }
|
|
102
|
+
const container = await createRequestContainer()
|
|
103
|
+
const rbacService = container.resolve<RbacService>('rbacService')
|
|
104
|
+
const acl = await rbacService.loadAcl(auth.sub, {
|
|
105
|
+
tenantId: auth.tenantId,
|
|
106
|
+
organizationId: auth.orgId,
|
|
107
|
+
})
|
|
108
|
+
if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {
|
|
109
|
+
return { kind: 'forbidden' }
|
|
110
|
+
}
|
|
111
|
+
const canManageConversations = hasRequiredFeatures(
|
|
112
|
+
[MANAGE_CONVERSATIONS_FEATURE],
|
|
113
|
+
acl.features,
|
|
114
|
+
acl.isSuperAdmin,
|
|
115
|
+
rbacService,
|
|
116
|
+
)
|
|
117
|
+
if (!auth.tenantId) {
|
|
118
|
+
return { kind: 'missing-tenant' }
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
kind: 'ok',
|
|
122
|
+
tenantId: auth.tenantId,
|
|
123
|
+
organizationId: auth.orgId ?? null,
|
|
124
|
+
userId: auth.sub,
|
|
125
|
+
canManageConversations,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function GET(req: NextRequest): Promise<Response> {
|
|
130
|
+
const callerCtx = await loadCallerContext(req)
|
|
131
|
+
if (callerCtx.kind === 'unauthorized') return jsonError(401, 'Unauthorized', 'unauthenticated')
|
|
132
|
+
if (callerCtx.kind === 'forbidden') {
|
|
133
|
+
return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
|
|
134
|
+
}
|
|
135
|
+
if (callerCtx.kind === 'missing-tenant') {
|
|
136
|
+
return NextResponse.json({ items: [], nextCursor: null })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const url = new URL(req.url)
|
|
140
|
+
const parseResult = aiChatConversationListQuerySchema.safeParse({
|
|
141
|
+
agent: url.searchParams.get('agent') ?? undefined,
|
|
142
|
+
status: url.searchParams.get('status') ?? undefined,
|
|
143
|
+
limit: url.searchParams.get('limit') ?? undefined,
|
|
144
|
+
cursor: url.searchParams.get('cursor') ?? undefined,
|
|
145
|
+
})
|
|
146
|
+
if (!parseResult.success) {
|
|
147
|
+
return jsonError(400, 'Invalid query parameters.', 'validation_error', {
|
|
148
|
+
issues: parseResult.error.issues,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const container = await createRequestContainer()
|
|
154
|
+
const repo = createConversationStorage(container)
|
|
155
|
+
const result = await repo.list(
|
|
156
|
+
{
|
|
157
|
+
tenantId: callerCtx.tenantId,
|
|
158
|
+
organizationId: callerCtx.organizationId,
|
|
159
|
+
userId: callerCtx.userId,
|
|
160
|
+
canManageConversations: callerCtx.canManageConversations,
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
agentId: parseResult.data.agent ?? null,
|
|
164
|
+
status: parseResult.data.status ?? null,
|
|
165
|
+
limit: parseResult.data.limit,
|
|
166
|
+
cursor: parseResult.data.cursor ?? null,
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
return NextResponse.json({
|
|
170
|
+
items: result.items.map(serializeAiChatConversation),
|
|
171
|
+
nextCursor: result.nextCursor,
|
|
172
|
+
})
|
|
173
|
+
} catch (error) {
|
|
174
|
+
console.error('[AI Conversations GET] Failure:', error)
|
|
175
|
+
return jsonError(
|
|
176
|
+
500,
|
|
177
|
+
error instanceof Error ? error.message : 'Failed to list conversations.',
|
|
178
|
+
'internal_error',
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function POST(req: NextRequest): Promise<Response> {
|
|
184
|
+
const callerCtx = await loadCallerContext(req)
|
|
185
|
+
if (callerCtx.kind === 'unauthorized') return jsonError(401, 'Unauthorized', 'unauthenticated')
|
|
186
|
+
if (callerCtx.kind === 'forbidden') {
|
|
187
|
+
return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
|
|
188
|
+
}
|
|
189
|
+
if (callerCtx.kind === 'missing-tenant') {
|
|
190
|
+
return jsonError(400, 'Caller is not bound to a tenant.', 'tenant_required')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let rawBody: unknown
|
|
194
|
+
try {
|
|
195
|
+
rawBody = await req.json()
|
|
196
|
+
} catch {
|
|
197
|
+
return jsonError(400, 'Request body must be valid JSON.', 'validation_error')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const parseResult = aiChatConversationCreateSchema.safeParse(rawBody)
|
|
201
|
+
if (!parseResult.success) {
|
|
202
|
+
return jsonError(400, 'Invalid conversation payload.', 'validation_error', {
|
|
203
|
+
issues: parseResult.error.issues,
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const container = await createRequestContainer()
|
|
209
|
+
const repo = createConversationStorage(container)
|
|
210
|
+
const ctx = {
|
|
211
|
+
tenantId: callerCtx.tenantId,
|
|
212
|
+
organizationId: callerCtx.organizationId,
|
|
213
|
+
userId: callerCtx.userId,
|
|
214
|
+
canManageConversations: false,
|
|
215
|
+
}
|
|
216
|
+
const beforeRow = parseResult.data.conversationId
|
|
217
|
+
? await repo.getById(parseResult.data.conversationId, ctx)
|
|
218
|
+
: null
|
|
219
|
+
const row = await repo.createOrGet(
|
|
220
|
+
{
|
|
221
|
+
conversationId: parseResult.data.conversationId,
|
|
222
|
+
agentId: parseResult.data.agentId,
|
|
223
|
+
title: parseResult.data.title ?? null,
|
|
224
|
+
pageContext: parseResult.data.pageContext ?? null,
|
|
225
|
+
},
|
|
226
|
+
ctx,
|
|
227
|
+
)
|
|
228
|
+
const status = beforeRow ? 200 : 201
|
|
229
|
+
return NextResponse.json(serializeAiChatConversation(row), { status })
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error instanceof Error && error.name === 'AiChatConversationAccessError') {
|
|
232
|
+
return jsonError(404, error.message, 'conversation_not_found')
|
|
233
|
+
}
|
|
234
|
+
console.error('[AI Conversations POST] Failure:', error)
|
|
235
|
+
return jsonError(
|
|
236
|
+
500,
|
|
237
|
+
error instanceof Error ? error.message : 'Failed to create conversation.',
|
|
238
|
+
'internal_error',
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -669,3 +669,258 @@ export class AiAgentMutationPolicyOverride {
|
|
|
669
669
|
@Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
|
|
670
670
|
updatedAt: Date = new Date()
|
|
671
671
|
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Tenant-scoped durable record of a typed AI chat session.
|
|
675
|
+
*
|
|
676
|
+
* Owner-only MVP per spec `2026-05-05-ai-chat-server-side-conversation-storage`.
|
|
677
|
+
* The `participants` table prepares for future sharing without a schema
|
|
678
|
+
* rewrite — the owner row is always written in the same transaction as the
|
|
679
|
+
* conversation row (see `AiChatConversationRepository.createOrGet`).
|
|
680
|
+
*
|
|
681
|
+
* `conversationId` is the stable, client-visible identifier. It is unique
|
|
682
|
+
* within `(tenant_id, organization_id)` so an idempotent `createOrGet` can
|
|
683
|
+
* accept a client-generated UUID. Pending mutation approvals already store
|
|
684
|
+
* the same id in `AiPendingAction.conversationId` — the chat-history schema
|
|
685
|
+
* deliberately matches that contract so a future foreign key can be added
|
|
686
|
+
* without churn.
|
|
687
|
+
*
|
|
688
|
+
* `imported_from_local_at` flags conversations that the UI lazily migrated
|
|
689
|
+
* from `localStorage`. The flag is informational only; once a row exists it
|
|
690
|
+
* is always the source of truth for that conversation.
|
|
691
|
+
*/
|
|
692
|
+
@Entity({ tableName: 'ai_chat_conversations' })
|
|
693
|
+
@Index({
|
|
694
|
+
name: 'ai_chat_conversations_tenant_org_conv_uq',
|
|
695
|
+
expression:
|
|
696
|
+
'create unique index "ai_chat_conversations_tenant_org_conv_uq" on "ai_chat_conversations" ("tenant_id", "organization_id", "conversation_id") where "organization_id" is not null and "deleted_at" is null',
|
|
697
|
+
})
|
|
698
|
+
@Index({
|
|
699
|
+
name: 'ai_chat_conversations_tenant_conv_null_org_uq',
|
|
700
|
+
expression:
|
|
701
|
+
'create unique index "ai_chat_conversations_tenant_conv_null_org_uq" on "ai_chat_conversations" ("tenant_id", "conversation_id") where "organization_id" is null and "deleted_at" is null',
|
|
702
|
+
})
|
|
703
|
+
@Index({
|
|
704
|
+
name: 'ai_chat_conversations_tenant_org_owner_agent_idx',
|
|
705
|
+
properties: ['tenantId', 'organizationId', 'ownerUserId', 'agentId', 'status', 'lastMessageAt'],
|
|
706
|
+
})
|
|
707
|
+
@Index({
|
|
708
|
+
name: 'ai_chat_conversations_tenant_org_deleted_idx',
|
|
709
|
+
properties: ['tenantId', 'organizationId', 'deletedAt'],
|
|
710
|
+
})
|
|
711
|
+
export class AiChatConversation {
|
|
712
|
+
[OptionalProps]?:
|
|
713
|
+
| 'createdAt'
|
|
714
|
+
| 'updatedAt'
|
|
715
|
+
| 'organizationId'
|
|
716
|
+
| 'title'
|
|
717
|
+
| 'status'
|
|
718
|
+
| 'visibility'
|
|
719
|
+
| 'pageContext'
|
|
720
|
+
| 'lastMessageAt'
|
|
721
|
+
| 'importedFromLocalAt'
|
|
722
|
+
| 'deletedAt'
|
|
723
|
+
|
|
724
|
+
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
725
|
+
id!: string
|
|
726
|
+
|
|
727
|
+
@Property({ name: 'tenant_id', type: 'uuid' })
|
|
728
|
+
tenantId!: string
|
|
729
|
+
|
|
730
|
+
@Property({ name: 'organization_id', type: 'uuid', nullable: true })
|
|
731
|
+
organizationId?: string | null
|
|
732
|
+
|
|
733
|
+
@Property({ name: 'conversation_id', type: 'text' })
|
|
734
|
+
conversationId!: string
|
|
735
|
+
|
|
736
|
+
@Property({ name: 'agent_id', type: 'text' })
|
|
737
|
+
agentId!: string
|
|
738
|
+
|
|
739
|
+
@Property({ name: 'owner_user_id', type: 'uuid' })
|
|
740
|
+
ownerUserId!: string
|
|
741
|
+
|
|
742
|
+
@Property({ name: 'title', type: 'text', nullable: true })
|
|
743
|
+
title?: string | null
|
|
744
|
+
|
|
745
|
+
@Property({ name: 'status', type: 'text', default: 'open' })
|
|
746
|
+
status: 'open' | 'closed' = 'open'
|
|
747
|
+
|
|
748
|
+
@Property({ name: 'visibility', type: 'text', default: 'private' })
|
|
749
|
+
visibility: 'private' | 'shared' | 'organization' = 'private'
|
|
750
|
+
|
|
751
|
+
@Property({ name: 'page_context', type: 'jsonb', nullable: true })
|
|
752
|
+
pageContext?: Record<string, unknown> | null
|
|
753
|
+
|
|
754
|
+
@Property({ name: 'last_message_at', type: Date, nullable: true })
|
|
755
|
+
lastMessageAt?: Date | null
|
|
756
|
+
|
|
757
|
+
@Property({ name: 'imported_from_local_at', type: Date, nullable: true })
|
|
758
|
+
importedFromLocalAt?: Date | null
|
|
759
|
+
|
|
760
|
+
@Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
|
|
761
|
+
createdAt: Date = new Date()
|
|
762
|
+
|
|
763
|
+
@Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
|
|
764
|
+
updatedAt: Date = new Date()
|
|
765
|
+
|
|
766
|
+
@Property({ name: 'deleted_at', type: Date, nullable: true })
|
|
767
|
+
deletedAt?: Date | null
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Membership row for an `AiChatConversation`.
|
|
772
|
+
*
|
|
773
|
+
* MVP always writes exactly one row per conversation with `role = 'owner'`,
|
|
774
|
+
* written transactionally alongside the conversation. Sharing extensions
|
|
775
|
+
* append additional rows with `role IN ('viewer', 'commenter', ...)`. The
|
|
776
|
+
* access predicate is "is the caller an undeleted participant" — see
|
|
777
|
+
* `AiChatConversationRepository.assertAccessible`.
|
|
778
|
+
*
|
|
779
|
+
* `last_read_at` is reserved for future unread/share UX and is unused in MVP.
|
|
780
|
+
*/
|
|
781
|
+
@Entity({ tableName: 'ai_chat_conversation_participants' })
|
|
782
|
+
@Index({
|
|
783
|
+
name: 'ai_chat_conv_participants_tenant_org_conv_user_uq',
|
|
784
|
+
expression:
|
|
785
|
+
'create unique index "ai_chat_conv_participants_tenant_org_conv_user_uq" on "ai_chat_conversation_participants" ("tenant_id", "organization_id", "conversation_id", "user_id") where "organization_id" is not null',
|
|
786
|
+
})
|
|
787
|
+
@Index({
|
|
788
|
+
name: 'ai_chat_conv_participants_tenant_conv_user_null_org_uq',
|
|
789
|
+
expression:
|
|
790
|
+
'create unique index "ai_chat_conv_participants_tenant_conv_user_null_org_uq" on "ai_chat_conversation_participants" ("tenant_id", "conversation_id", "user_id") where "organization_id" is null',
|
|
791
|
+
})
|
|
792
|
+
@Index({
|
|
793
|
+
name: 'ai_chat_conv_participants_tenant_org_user_conv_idx',
|
|
794
|
+
properties: ['tenantId', 'organizationId', 'userId', 'conversationId'],
|
|
795
|
+
})
|
|
796
|
+
export class AiChatConversationParticipant {
|
|
797
|
+
[OptionalProps]?:
|
|
798
|
+
| 'createdAt'
|
|
799
|
+
| 'updatedAt'
|
|
800
|
+
| 'organizationId'
|
|
801
|
+
| 'role'
|
|
802
|
+
| 'lastReadAt'
|
|
803
|
+
|
|
804
|
+
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
805
|
+
id!: string
|
|
806
|
+
|
|
807
|
+
@Property({ name: 'tenant_id', type: 'uuid' })
|
|
808
|
+
tenantId!: string
|
|
809
|
+
|
|
810
|
+
@Property({ name: 'organization_id', type: 'uuid', nullable: true })
|
|
811
|
+
organizationId?: string | null
|
|
812
|
+
|
|
813
|
+
@Property({ name: 'conversation_id', type: 'text' })
|
|
814
|
+
conversationId!: string
|
|
815
|
+
|
|
816
|
+
@Property({ name: 'user_id', type: 'uuid' })
|
|
817
|
+
userId!: string
|
|
818
|
+
|
|
819
|
+
@Property({ name: 'role', type: 'text', default: 'owner' })
|
|
820
|
+
role: 'owner' | 'viewer' | 'commenter' = 'owner'
|
|
821
|
+
|
|
822
|
+
@Property({ name: 'last_read_at', type: Date, nullable: true })
|
|
823
|
+
lastReadAt?: Date | null
|
|
824
|
+
|
|
825
|
+
@Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
|
|
826
|
+
createdAt: Date = new Date()
|
|
827
|
+
|
|
828
|
+
@Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
|
|
829
|
+
updatedAt: Date = new Date()
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Append-only message row for an `AiChatConversation`.
|
|
834
|
+
*
|
|
835
|
+
* `client_message_id` is the idempotency key for retries and lazy imports
|
|
836
|
+
* from `localStorage`. The partial unique index allows a non-null
|
|
837
|
+
* `clientMessageId` to dedupe within the conversation while leaving
|
|
838
|
+
* server-only rows (assistant turns persisted from the streaming dispatcher)
|
|
839
|
+
* free of the constraint.
|
|
840
|
+
*
|
|
841
|
+
* `ui_parts` stores the serializable subset of `AiChatMessageUiPart[]` so the
|
|
842
|
+
* chat surface can re-render record cards, mutation-preview cards, etc.
|
|
843
|
+
* across reloads. Attachment previews (`data:` URLs and transient blob
|
|
844
|
+
* URLs) MUST NOT be persisted here — the UI strips them before upload.
|
|
845
|
+
*/
|
|
846
|
+
@Entity({ tableName: 'ai_chat_messages' })
|
|
847
|
+
@Index({
|
|
848
|
+
name: 'ai_chat_messages_tenant_org_conv_client_id_uq',
|
|
849
|
+
expression:
|
|
850
|
+
'create unique index "ai_chat_messages_tenant_org_conv_client_id_uq" on "ai_chat_messages" ("tenant_id", "organization_id", "conversation_id", "client_message_id") where "organization_id" is not null and "client_message_id" is not null and "deleted_at" is null',
|
|
851
|
+
})
|
|
852
|
+
@Index({
|
|
853
|
+
name: 'ai_chat_messages_tenant_conv_client_id_null_org_uq',
|
|
854
|
+
expression:
|
|
855
|
+
'create unique index "ai_chat_messages_tenant_conv_client_id_null_org_uq" on "ai_chat_messages" ("tenant_id", "conversation_id", "client_message_id") where "organization_id" is null and "client_message_id" is not null and "deleted_at" is null',
|
|
856
|
+
})
|
|
857
|
+
@Index({
|
|
858
|
+
name: 'ai_chat_messages_tenant_org_conv_created_idx',
|
|
859
|
+
properties: ['tenantId', 'organizationId', 'conversationId', 'createdAt'],
|
|
860
|
+
})
|
|
861
|
+
@Index({
|
|
862
|
+
name: 'ai_chat_messages_tenant_org_deleted_idx',
|
|
863
|
+
properties: ['tenantId', 'organizationId', 'deletedAt'],
|
|
864
|
+
})
|
|
865
|
+
export class AiChatMessage {
|
|
866
|
+
[OptionalProps]?:
|
|
867
|
+
| 'createdAt'
|
|
868
|
+
| 'updatedAt'
|
|
869
|
+
| 'organizationId'
|
|
870
|
+
| 'clientMessageId'
|
|
871
|
+
| 'uiParts'
|
|
872
|
+
| 'attachmentIds'
|
|
873
|
+
| 'filesMetadata'
|
|
874
|
+
| 'model'
|
|
875
|
+
| 'metadata'
|
|
876
|
+
| 'createdByUserId'
|
|
877
|
+
| 'deletedAt'
|
|
878
|
+
|
|
879
|
+
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
880
|
+
id!: string
|
|
881
|
+
|
|
882
|
+
@Property({ name: 'tenant_id', type: 'uuid' })
|
|
883
|
+
tenantId!: string
|
|
884
|
+
|
|
885
|
+
@Property({ name: 'organization_id', type: 'uuid', nullable: true })
|
|
886
|
+
organizationId?: string | null
|
|
887
|
+
|
|
888
|
+
@Property({ name: 'conversation_id', type: 'text' })
|
|
889
|
+
conversationId!: string
|
|
890
|
+
|
|
891
|
+
@Property({ name: 'client_message_id', type: 'text', nullable: true })
|
|
892
|
+
clientMessageId?: string | null
|
|
893
|
+
|
|
894
|
+
@Property({ name: 'role', type: 'text' })
|
|
895
|
+
role!: 'user' | 'assistant' | 'system'
|
|
896
|
+
|
|
897
|
+
@Property({ name: 'content', type: 'text' })
|
|
898
|
+
content!: string
|
|
899
|
+
|
|
900
|
+
@Property({ name: 'ui_parts', type: 'jsonb', nullable: true })
|
|
901
|
+
uiParts?: unknown[] | null
|
|
902
|
+
|
|
903
|
+
@Property({ name: 'attachment_ids', type: 'jsonb', nullable: true })
|
|
904
|
+
attachmentIds?: string[] | null
|
|
905
|
+
|
|
906
|
+
@Property({ name: 'files_metadata', type: 'jsonb', nullable: true })
|
|
907
|
+
filesMetadata?: Array<Record<string, unknown>> | null
|
|
908
|
+
|
|
909
|
+
@Property({ name: 'model', type: 'text', nullable: true })
|
|
910
|
+
model?: string | null
|
|
911
|
+
|
|
912
|
+
@Property({ name: 'metadata', type: 'jsonb', nullable: true })
|
|
913
|
+
metadata?: Record<string, unknown> | null
|
|
914
|
+
|
|
915
|
+
@Property({ name: 'created_by_user_id', type: 'uuid', nullable: true })
|
|
916
|
+
createdByUserId?: string | null
|
|
917
|
+
|
|
918
|
+
@Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
|
|
919
|
+
createdAt: Date = new Date()
|
|
920
|
+
|
|
921
|
+
@Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
|
|
922
|
+
updatedAt: Date = new Date()
|
|
923
|
+
|
|
924
|
+
@Property({ name: 'deleted_at', type: Date, nullable: true })
|
|
925
|
+
deletedAt?: Date | null
|
|
926
|
+
}
|