@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,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
+ }
@@ -0,0 +1,2 @@
1
+ export { AiChatConversation } from '../entities'
2
+ export type { AiChatConversation as default } from '../entities'
@@ -0,0 +1,2 @@
1
+ export { AiChatConversationParticipant } from '../entities'
2
+ export type { AiChatConversationParticipant as default } from '../entities'
@@ -0,0 +1,2 @@
1
+ export { AiChatMessage } from '../entities'
2
+ export type { AiChatMessage as default } from '../entities'
@@ -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
+ }