@open-mercato/core 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2

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 (163) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +13 -1
  3. package/dist/helpers/integration/api.js +29 -16
  4. package/dist/helpers/integration/api.js.map +2 -2
  5. package/dist/helpers/integration/auth.js +11 -6
  6. package/dist/helpers/integration/auth.js.map +3 -3
  7. package/dist/modules/auth/commands/roles.js +9 -12
  8. package/dist/modules/auth/commands/roles.js.map +2 -2
  9. package/dist/modules/catalog/ai-agents-context.js +147 -0
  10. package/dist/modules/catalog/ai-agents-context.js.map +7 -0
  11. package/dist/modules/catalog/ai-agents.js +383 -0
  12. package/dist/modules/catalog/ai-agents.js.map +7 -0
  13. package/dist/modules/catalog/ai-tools/_shared.js +318 -0
  14. package/dist/modules/catalog/ai-tools/_shared.js.map +7 -0
  15. package/dist/modules/catalog/ai-tools/authoring-pack.js +391 -0
  16. package/dist/modules/catalog/ai-tools/authoring-pack.js.map +7 -0
  17. package/dist/modules/catalog/ai-tools/categories-pack.js +167 -0
  18. package/dist/modules/catalog/ai-tools/categories-pack.js.map +7 -0
  19. package/dist/modules/catalog/ai-tools/configuration-pack.js +120 -0
  20. package/dist/modules/catalog/ai-tools/configuration-pack.js.map +7 -0
  21. package/dist/modules/catalog/ai-tools/media-tags-pack.js +107 -0
  22. package/dist/modules/catalog/ai-tools/media-tags-pack.js.map +7 -0
  23. package/dist/modules/catalog/ai-tools/merchandising-pack.js +429 -0
  24. package/dist/modules/catalog/ai-tools/merchandising-pack.js.map +7 -0
  25. package/dist/modules/catalog/ai-tools/mutation-pack.js +576 -0
  26. package/dist/modules/catalog/ai-tools/mutation-pack.js.map +7 -0
  27. package/dist/modules/catalog/ai-tools/prices-offers-pack.js +208 -0
  28. package/dist/modules/catalog/ai-tools/prices-offers-pack.js.map +7 -0
  29. package/dist/modules/catalog/ai-tools/products-pack.js +298 -0
  30. package/dist/modules/catalog/ai-tools/products-pack.js.map +7 -0
  31. package/dist/modules/catalog/ai-tools/stats-pack.js +57 -0
  32. package/dist/modules/catalog/ai-tools/stats-pack.js.map +7 -0
  33. package/dist/modules/catalog/ai-tools/types.js +10 -0
  34. package/dist/modules/catalog/ai-tools/types.js.map +7 -0
  35. package/dist/modules/catalog/ai-tools/variants-pack.js +75 -0
  36. package/dist/modules/catalog/ai-tools/variants-pack.js.map +7 -0
  37. package/dist/modules/catalog/ai-tools.js +28 -0
  38. package/dist/modules/catalog/ai-tools.js.map +7 -0
  39. package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js +466 -0
  40. package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js.map +7 -0
  41. package/dist/modules/catalog/backend/catalog/products/page.js +7 -1
  42. package/dist/modules/catalog/backend/catalog/products/page.js.map +2 -2
  43. package/dist/modules/catalog/components/CatalogStatsCard.js +91 -0
  44. package/dist/modules/catalog/components/CatalogStatsCard.js.map +7 -0
  45. package/dist/modules/catalog/components/products/ProductsDataTable.js +23 -3
  46. package/dist/modules/catalog/components/products/ProductsDataTable.js.map +2 -2
  47. package/dist/modules/catalog/events.js +7 -4
  48. package/dist/modules/catalog/events.js.map +2 -2
  49. package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.js +59 -0
  50. package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.js.map +7 -0
  51. package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.js +17 -0
  52. package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.js.map +7 -0
  53. package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js +1 -1
  54. package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js.map +2 -2
  55. package/dist/modules/catalog/widgets/injection-table.js +13 -1
  56. package/dist/modules/catalog/widgets/injection-table.js.map +2 -2
  57. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js +94 -0
  58. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js.map +7 -0
  59. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.js +17 -0
  60. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.js.map +7 -0
  61. package/dist/modules/customer_accounts/widgets/injection-table.js +9 -0
  62. package/dist/modules/customer_accounts/widgets/injection-table.js.map +2 -2
  63. package/dist/modules/customers/ai-agents-context.js +96 -0
  64. package/dist/modules/customers/ai-agents-context.js.map +7 -0
  65. package/dist/modules/customers/ai-agents.js +244 -0
  66. package/dist/modules/customers/ai-agents.js.map +7 -0
  67. package/dist/modules/customers/ai-tools/activities-tasks-pack.js +1015 -0
  68. package/dist/modules/customers/ai-tools/activities-tasks-pack.js.map +7 -0
  69. package/dist/modules/customers/ai-tools/addresses-tags-pack.js +134 -0
  70. package/dist/modules/customers/ai-tools/addresses-tags-pack.js.map +7 -0
  71. package/dist/modules/customers/ai-tools/companies-pack.js +249 -0
  72. package/dist/modules/customers/ai-tools/companies-pack.js.map +7 -0
  73. package/dist/modules/customers/ai-tools/deals-pack.js +348 -0
  74. package/dist/modules/customers/ai-tools/deals-pack.js.map +7 -0
  75. package/dist/modules/customers/ai-tools/people-pack.js +261 -0
  76. package/dist/modules/customers/ai-tools/people-pack.js.map +7 -0
  77. package/dist/modules/customers/ai-tools/settings-pack.js +102 -0
  78. package/dist/modules/customers/ai-tools/settings-pack.js.map +7 -0
  79. package/dist/modules/customers/ai-tools/types.js +10 -0
  80. package/dist/modules/customers/ai-tools/types.js.map +7 -0
  81. package/dist/modules/customers/ai-tools.js +20 -0
  82. package/dist/modules/customers/ai-tools.js.map +7 -0
  83. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js +469 -0
  84. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js.map +7 -0
  85. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.js +17 -0
  86. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.js.map +7 -0
  87. package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.js +117 -0
  88. package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.js.map +7 -0
  89. package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.js +17 -0
  90. package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.js.map +7 -0
  91. package/dist/modules/customers/widgets/injection-table.js +26 -0
  92. package/dist/modules/customers/widgets/injection-table.js.map +7 -0
  93. package/dist/modules/inbox_ops/ai-tools.js +4 -0
  94. package/dist/modules/inbox_ops/ai-tools.js.map +2 -2
  95. package/dist/modules/inbox_ops/lib/llmProvider.js +52 -7
  96. package/dist/modules/inbox_ops/lib/llmProvider.js.map +2 -2
  97. package/dist/modules/notifications/setup.js +13 -0
  98. package/dist/modules/notifications/setup.js.map +7 -0
  99. package/jest.config.cjs +1 -0
  100. package/jest.setup.ts +18 -0
  101. package/package.json +5 -3
  102. package/src/helpers/integration/api.ts +38 -16
  103. package/src/helpers/integration/auth.ts +13 -6
  104. package/src/modules/auth/commands/roles.ts +10 -12
  105. package/src/modules/catalog/AGENTS.md +11 -0
  106. package/src/modules/catalog/ai-agents-context.ts +239 -0
  107. package/src/modules/catalog/ai-agents.ts +525 -0
  108. package/src/modules/catalog/ai-tools/_shared.ts +487 -0
  109. package/src/modules/catalog/ai-tools/authoring-pack.ts +600 -0
  110. package/src/modules/catalog/ai-tools/categories-pack.ts +192 -0
  111. package/src/modules/catalog/ai-tools/configuration-pack.ts +218 -0
  112. package/src/modules/catalog/ai-tools/media-tags-pack.ts +127 -0
  113. package/src/modules/catalog/ai-tools/merchandising-pack.ts +608 -0
  114. package/src/modules/catalog/ai-tools/mutation-pack.ts +761 -0
  115. package/src/modules/catalog/ai-tools/prices-offers-pack.ts +376 -0
  116. package/src/modules/catalog/ai-tools/products-pack.ts +387 -0
  117. package/src/modules/catalog/ai-tools/stats-pack.ts +84 -0
  118. package/src/modules/catalog/ai-tools/types.ts +81 -0
  119. package/src/modules/catalog/ai-tools/variants-pack.ts +147 -0
  120. package/src/modules/catalog/ai-tools.ts +78 -0
  121. package/src/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.tsx +597 -0
  122. package/src/modules/catalog/backend/catalog/products/page.tsx +23 -2
  123. package/src/modules/catalog/components/CatalogStatsCard.tsx +118 -0
  124. package/src/modules/catalog/components/products/ProductsDataTable.tsx +54 -6
  125. package/src/modules/catalog/events.ts +7 -4
  126. package/src/modules/catalog/i18n/de.json +17 -0
  127. package/src/modules/catalog/i18n/en.json +17 -0
  128. package/src/modules/catalog/i18n/es.json +17 -0
  129. package/src/modules/catalog/i18n/pl.json +17 -0
  130. package/src/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.tsx +109 -0
  131. package/src/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.ts +29 -0
  132. package/src/modules/catalog/widgets/injection/product-seo/widget.client.tsx +1 -1
  133. package/src/modules/catalog/widgets/injection-table.ts +12 -0
  134. package/src/modules/customer_accounts/i18n/de.json +5 -0
  135. package/src/modules/customer_accounts/i18n/en.json +5 -0
  136. package/src/modules/customer_accounts/i18n/es.json +5 -0
  137. package/src/modules/customer_accounts/i18n/pl.json +5 -0
  138. package/src/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.tsx +136 -0
  139. package/src/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.ts +43 -0
  140. package/src/modules/customer_accounts/widgets/injection-table.ts +9 -0
  141. package/src/modules/customers/AGENTS.md +13 -0
  142. package/src/modules/customers/ai-agents-context.ts +150 -0
  143. package/src/modules/customers/ai-agents.ts +355 -0
  144. package/src/modules/customers/ai-tools/activities-tasks-pack.ts +1248 -0
  145. package/src/modules/customers/ai-tools/addresses-tags-pack.ts +145 -0
  146. package/src/modules/customers/ai-tools/companies-pack.ts +362 -0
  147. package/src/modules/customers/ai-tools/deals-pack.ts +505 -0
  148. package/src/modules/customers/ai-tools/people-pack.ts +369 -0
  149. package/src/modules/customers/ai-tools/settings-pack.ts +121 -0
  150. package/src/modules/customers/ai-tools/types.ts +76 -0
  151. package/src/modules/customers/ai-tools.ts +34 -0
  152. package/src/modules/customers/i18n/de.json +25 -0
  153. package/src/modules/customers/i18n/en.json +25 -0
  154. package/src/modules/customers/i18n/es.json +25 -0
  155. package/src/modules/customers/i18n/pl.json +25 -0
  156. package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.tsx +580 -0
  157. package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.ts +36 -0
  158. package/src/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.tsx +191 -0
  159. package/src/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.ts +37 -0
  160. package/src/modules/customers/widgets/injection-table.ts +41 -0
  161. package/src/modules/inbox_ops/ai-tools.ts +4 -0
  162. package/src/modules/inbox_ops/lib/llmProvider.ts +83 -7
  163. package/src/modules/notifications/setup.ts +11 -0
@@ -0,0 +1,369 @@
1
+ /**
2
+ * `customers.list_people` + `customers.get_person` (Phase 1 WS-C, Step 3.9).
3
+ *
4
+ * Read-only tools scoped to `ctx.tenantId` / `ctx.organizationId` that wrap
5
+ * the existing customers query engine + encryption helpers. Mutation tools
6
+ * are deferred to Step 5.13+ under the pending-action contract.
7
+ *
8
+ * Phase 3a of `.ai/specs/2026-04-27-ai-tools-api-backed-dry-refactor.md`:
9
+ * `customers.list_people` is now an API-backed wrapper over
10
+ * `GET /api/customers/people`. The `companyId` AI input has no inclusion
11
+ * equivalent on the route (the route exposes `excludeLinkedCompanyId` only)
12
+ * so it is pre-resolved against `CustomerPersonProfile.company` and threaded
13
+ * through the route's `ids` filter.
14
+ *
15
+ * Phase 3c of the same spec migrates `customers.get_person` to a single
16
+ * in-process call to `GET /api/customers/people/<id>?include=...` (the
17
+ * documented aggregate detail route). Tool name, schema, requiredFeatures,
18
+ * and output shape are unchanged.
19
+ */
20
+ import type { EntityManager } from '@mikro-orm/postgresql'
21
+ import { z } from 'zod'
22
+ import { defineApiBackedAiTool } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/api-backed-tool'
23
+ import {
24
+ createAiApiOperationRunner,
25
+ type AiApiOperationRequest,
26
+ type AiToolExecutionContext,
27
+ } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-api-operation-runner'
28
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
29
+ import {
30
+ CustomerPersonProfile,
31
+ } from '../data/entities'
32
+ import { assertTenantScope, type CustomersAiToolDefinition, type CustomersToolContext } from './types'
33
+
34
+ const NIL_UUID = '00000000-0000-0000-0000-000000000000'
35
+
36
+ function resolveEm(ctx: CustomersToolContext | AiToolExecutionContext): EntityManager {
37
+ return ctx.container.resolve<EntityManager>('em')
38
+ }
39
+
40
+ function buildScope(ctx: CustomersToolContext | AiToolExecutionContext, tenantId: string) {
41
+ return {
42
+ tenantId,
43
+ organizationId: ctx.organizationId,
44
+ }
45
+ }
46
+
47
+ const listPeopleInput = z
48
+ .object({
49
+ q: z.string().trim().optional().describe('Optional search text matched against display name / email / phone. Omit or leave empty to list all.'),
50
+ limit: z.number().int().min(1).max(100).optional().describe('Maximum rows to return (default 50, max 100).'),
51
+ offset: z.number().int().min(0).optional().describe('Number of rows to skip (default 0).'),
52
+ tags: z.array(z.string().uuid()).optional().describe('Restrict to persons carrying at least one of these tag ids.'),
53
+ companyId: z.string().uuid().optional().describe('Restrict to persons linked to the given company entity.'),
54
+ })
55
+ .passthrough()
56
+
57
+ type ListPeopleInput = z.infer<typeof listPeopleInput>
58
+
59
+ type ListPeopleApiItem = {
60
+ id?: string
61
+ display_name?: string | null
62
+ displayName?: string | null
63
+ primary_email?: string | null
64
+ primaryEmail?: string | null
65
+ primary_phone?: string | null
66
+ primaryPhone?: string | null
67
+ status?: string | null
68
+ lifecycle_stage?: string | null
69
+ lifecycleStage?: string | null
70
+ source?: string | null
71
+ owner_user_id?: string | null
72
+ ownerUserId?: string | null
73
+ organization_id?: string | null
74
+ organizationId?: string | null
75
+ tenant_id?: string | null
76
+ tenantId?: string | null
77
+ created_at?: string | null
78
+ createdAt?: string | null
79
+ }
80
+
81
+ type ListPeopleApiResponse = {
82
+ items?: ListPeopleApiItem[]
83
+ total?: number
84
+ }
85
+
86
+ type ListPeopleOutput = {
87
+ items: Array<Record<string, unknown>>
88
+ total: number
89
+ limit: number
90
+ offset: number
91
+ }
92
+
93
+ const listPeopleTool = defineApiBackedAiTool<ListPeopleInput, ListPeopleApiResponse, ListPeopleOutput>({
94
+ name: 'customers.list_people',
95
+ displayName: 'List people',
96
+ description:
97
+ 'Search / list people (CRM persons) for the caller tenant + organization. Returns { items, total, limit, offset }.',
98
+ inputSchema: listPeopleInput,
99
+ requiredFeatures: ['customers.people.view'],
100
+ toOperation: async (input, ctx) => {
101
+ const { tenantId } = assertTenantScope(ctx as unknown as CustomersToolContext)
102
+ const limit = input.limit ?? 50
103
+ const offset = input.offset ?? 0
104
+ const page = Math.floor(offset / limit) + 1
105
+
106
+ const query: Record<string, string | number | boolean | null | undefined> = {
107
+ page,
108
+ pageSize: limit,
109
+ }
110
+ if (input.q?.trim()) query.search = input.q.trim()
111
+ if (input.tags && input.tags.length > 0) query.tagIds = input.tags.join(',')
112
+
113
+ if (input.companyId) {
114
+ const em = resolveEm(ctx)
115
+ const profiles = await findWithDecryption<CustomerPersonProfile>(
116
+ em,
117
+ CustomerPersonProfile,
118
+ { tenantId, company: input.companyId } as never,
119
+ undefined,
120
+ buildScope(ctx, tenantId),
121
+ )
122
+ const ids = profiles
123
+ .map((profile) => {
124
+ const entity = (profile as { entity?: unknown }).entity
125
+ if (!entity) return null
126
+ if (typeof entity === 'string') return entity
127
+ const candidate = (entity as { id?: unknown }).id
128
+ return typeof candidate === 'string' ? candidate : null
129
+ })
130
+ .filter((value): value is string => typeof value === 'string' && value.length > 0)
131
+ // Empty match — feed a non-existent uuid so the route returns
132
+ // { items: [], total: 0 } without us bypassing the API.
133
+ query.ids = ids.length ? ids.join(',') : NIL_UUID
134
+ }
135
+
136
+ const operation: AiApiOperationRequest = {
137
+ method: 'GET',
138
+ path: '/customers/people',
139
+ query,
140
+ }
141
+ return operation
142
+ },
143
+ mapResponse: (response, input) => {
144
+ const limit = input.limit ?? 50
145
+ const offset = input.offset ?? 0
146
+ const data = (response.data ?? {}) as ListPeopleApiResponse
147
+ const rawItems: ListPeopleApiItem[] = Array.isArray(data.items) ? data.items : []
148
+ return {
149
+ items: rawItems.map((row) => {
150
+ const createdAtRaw = row.created_at ?? row.createdAt ?? null
151
+ const createdAt = createdAtRaw ? new Date(String(createdAtRaw)).toISOString() : null
152
+ return {
153
+ id: row.id,
154
+ displayName: row.display_name ?? row.displayName ?? null,
155
+ primaryEmail: row.primary_email ?? row.primaryEmail ?? null,
156
+ primaryPhone: row.primary_phone ?? row.primaryPhone ?? null,
157
+ status: row.status ?? null,
158
+ lifecycleStage: row.lifecycle_stage ?? row.lifecycleStage ?? null,
159
+ source: row.source ?? null,
160
+ ownerUserId: row.owner_user_id ?? row.ownerUserId ?? null,
161
+ organizationId: row.organization_id ?? row.organizationId ?? null,
162
+ tenantId: row.tenant_id ?? row.tenantId ?? null,
163
+ createdAt,
164
+ }
165
+ }),
166
+ total: typeof data.total === 'number' ? data.total : 0,
167
+ limit,
168
+ offset,
169
+ }
170
+ },
171
+ }) as unknown as CustomersAiToolDefinition
172
+
173
+ const getPersonInput = z.object({
174
+ personId: z.string().uuid().describe('Person entity id (UUID).'),
175
+ includeRelated: z
176
+ .boolean()
177
+ .optional()
178
+ .describe('When true, include notes, activities, deals, addresses, tasks, and tags (each capped at 100).'),
179
+ })
180
+
181
+ type GetPersonInput = z.infer<typeof getPersonInput>
182
+
183
+ type ApiPersonDetailRow = Record<string, unknown> | null | undefined
184
+
185
+ function toIso(value: unknown): string | null {
186
+ if (!value) return null
187
+ const dt = value instanceof Date ? value : new Date(String(value))
188
+ if (Number.isNaN(dt.getTime())) return null
189
+ return dt.toISOString()
190
+ }
191
+
192
+ const getPersonTool: CustomersAiToolDefinition = {
193
+ name: 'customers.get_person',
194
+ displayName: 'Get person',
195
+ description:
196
+ 'Fetch a person customer record by id with profile fields and (optionally) notes, activities, deals, addresses, tasks, tags, and custom fields. Returns { found: false } when the record is outside tenant/org scope or missing.',
197
+ inputSchema: getPersonInput,
198
+ requiredFeatures: ['customers.people.view'],
199
+ tags: ['read', 'customers'],
200
+ handler: async (rawInput, ctx) => {
201
+ const { tenantId } = assertTenantScope(ctx)
202
+ const input: GetPersonInput = getPersonInput.parse(rawInput)
203
+ const includeRelated = !!input.includeRelated
204
+
205
+ const operation: AiApiOperationRequest = {
206
+ method: 'GET',
207
+ path: `/customers/people/${input.personId}`,
208
+ }
209
+ if (includeRelated) {
210
+ operation.query = { include: 'addresses,comments,activities,interactions,deals,todos' }
211
+ }
212
+
213
+ const runner = createAiApiOperationRunner(ctx as unknown as AiToolExecutionContext)
214
+ const response = await runner.run<Record<string, unknown>>(operation)
215
+ if (!response.success) {
216
+ if (response.statusCode === 404 || response.statusCode === 403) {
217
+ return { found: false as const, personId: input.personId }
218
+ }
219
+ throw new Error(response.error ?? `Failed to fetch person ${input.personId}`)
220
+ }
221
+ const data = (response.data ?? {}) as Record<string, unknown>
222
+ const personRow = (data.person ?? null) as ApiPersonDetailRow
223
+ if (!personRow) {
224
+ return { found: false as const, personId: input.personId }
225
+ }
226
+ const profileRow = (data.profile ?? null) as ApiPersonDetailRow
227
+ const customFields = (data.customFields ?? {}) as Record<string, unknown>
228
+
229
+ let related: Record<string, unknown> | null = null
230
+ if (includeRelated) {
231
+ const addresses = Array.isArray(data.addresses) ? (data.addresses as Array<Record<string, unknown>>) : []
232
+ const activities = Array.isArray(data.activities) ? (data.activities as Array<Record<string, unknown>>) : []
233
+ const notes = Array.isArray(data.comments) ? (data.comments as Array<Record<string, unknown>>) : []
234
+ const todos = Array.isArray(data.todos) ? (data.todos as Array<Record<string, unknown>>) : []
235
+ const interactions = Array.isArray(data.interactions) ? (data.interactions as Array<Record<string, unknown>>) : []
236
+ const tagsRows = Array.isArray(data.tags) ? (data.tags as Array<Record<string, unknown>>) : []
237
+ const dealsRows = Array.isArray(data.deals) ? (data.deals as Array<Record<string, unknown>>) : []
238
+ related = {
239
+ addresses: addresses.map((address) => ({
240
+ id: address.id,
241
+ name: address.name ?? null,
242
+ purpose: address.purpose ?? null,
243
+ addressLine1: address.addressLine1 ?? null,
244
+ addressLine2: address.addressLine2 ?? null,
245
+ city: address.city ?? null,
246
+ region: address.region ?? null,
247
+ postalCode: address.postalCode ?? null,
248
+ country: address.country ?? null,
249
+ isPrimary: !!address.isPrimary,
250
+ })),
251
+ activities: activities.map((activity) => ({
252
+ id: activity.id,
253
+ activityType: activity.activityType,
254
+ subject: activity.subject ?? null,
255
+ body: activity.body ?? null,
256
+ occurredAt: toIso(activity.occurredAt),
257
+ createdAt: toIso(activity.createdAt),
258
+ })),
259
+ notes: notes.map((comment) => ({
260
+ id: comment.id,
261
+ body: comment.body,
262
+ authorUserId: comment.authorUserId ?? null,
263
+ createdAt: toIso(comment.createdAt),
264
+ })),
265
+ tasks: todos.map((task) => ({
266
+ id: task.id,
267
+ todoId: task.todoId ?? task.id,
268
+ todoSource: task.todoSource ?? null,
269
+ createdAt: toIso(task.createdAt),
270
+ })),
271
+ interactions: interactions.map((interaction) => ({
272
+ id: interaction.id,
273
+ interactionType: interaction.interactionType,
274
+ title: interaction.title ?? null,
275
+ status: interaction.status,
276
+ scheduledAt: toIso(interaction.scheduledAt),
277
+ occurredAt: toIso(interaction.occurredAt),
278
+ })),
279
+ tags: tagsRows
280
+ .map((tag) => {
281
+ if (!tag || typeof tag !== 'object') return null
282
+ const id = typeof tag.id === 'string' ? tag.id : null
283
+ const label = typeof tag.label === 'string' ? tag.label : null
284
+ if (!id || !label) return null
285
+ const slug = typeof tag.slug === 'string' ? tag.slug : label
286
+ const color = typeof tag.color === 'string' ? tag.color : null
287
+ return { id, slug, label, color }
288
+ })
289
+ .filter(
290
+ (entry): entry is { id: string; slug: string; label: string; color: string | null } =>
291
+ entry !== null,
292
+ ),
293
+ deals: dealsRows
294
+ .map((deal) => {
295
+ if (!deal || typeof deal !== 'object') return null
296
+ const id = typeof deal.id === 'string' ? deal.id : null
297
+ if (!id) return null
298
+ return {
299
+ id,
300
+ title: typeof deal.title === 'string' ? deal.title : '',
301
+ status: typeof deal.status === 'string' ? deal.status : null,
302
+ pipelineStageId:
303
+ typeof deal.pipelineStageId === 'string' ? deal.pipelineStageId : null,
304
+ valueAmount:
305
+ typeof deal.valueAmount === 'string'
306
+ ? deal.valueAmount
307
+ : deal.valueAmount === null || deal.valueAmount === undefined
308
+ ? null
309
+ : String(deal.valueAmount),
310
+ valueCurrency:
311
+ typeof deal.valueCurrency === 'string' ? deal.valueCurrency : null,
312
+ }
313
+ })
314
+ .filter(
315
+ (
316
+ value,
317
+ ): value is {
318
+ id: string
319
+ title: string
320
+ status: string | null
321
+ pipelineStageId: string | null
322
+ valueAmount: string | null
323
+ valueCurrency: string | null
324
+ } => value !== null,
325
+ ),
326
+ }
327
+ }
328
+
329
+ return {
330
+ found: true as const,
331
+ person: {
332
+ id: personRow.id,
333
+ displayName: personRow.displayName ?? null,
334
+ description: personRow.description ?? null,
335
+ primaryEmail: personRow.primaryEmail ?? null,
336
+ primaryPhone: personRow.primaryPhone ?? null,
337
+ status: personRow.status ?? null,
338
+ lifecycleStage: personRow.lifecycleStage ?? null,
339
+ source: personRow.source ?? null,
340
+ ownerUserId: personRow.ownerUserId ?? null,
341
+ organizationId: personRow.organizationId ?? null,
342
+ tenantId: personRow.tenantId ?? null,
343
+ createdAt: toIso(personRow.createdAt),
344
+ updatedAt: toIso(personRow.updatedAt),
345
+ },
346
+ profile: profileRow
347
+ ? {
348
+ id: profileRow.id,
349
+ firstName: profileRow.firstName ?? null,
350
+ lastName: profileRow.lastName ?? null,
351
+ preferredName: profileRow.preferredName ?? null,
352
+ jobTitle: profileRow.jobTitle ?? null,
353
+ department: profileRow.department ?? null,
354
+ seniority: profileRow.seniority ?? null,
355
+ timezone: profileRow.timezone ?? null,
356
+ linkedInUrl: profileRow.linkedInUrl ?? null,
357
+ twitterUrl: profileRow.twitterUrl ?? null,
358
+ companyEntityId: profileRow.companyEntityId ?? null,
359
+ }
360
+ : null,
361
+ customFields,
362
+ related,
363
+ }
364
+ },
365
+ }
366
+
367
+ export const peopleAiTools: CustomersAiToolDefinition[] = [listPeopleTool, getPersonTool]
368
+
369
+ export default peopleAiTools
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `customers.get_settings` (Phase 1 WS-C, Step 3.9).
3
+ *
4
+ * Aggregates the four settings surfaces the spec calls out: pipelines,
5
+ * pipeline stages, dictionaries, and address-format settings. All reads are
6
+ * tenant + organization scoped through the existing encryption helpers.
7
+ */
8
+ import type { EntityManager } from '@mikro-orm/postgresql'
9
+ import { z } from 'zod'
10
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
11
+ import {
12
+ CustomerDictionaryEntry,
13
+ CustomerPipeline,
14
+ CustomerPipelineStage,
15
+ CustomerSettings,
16
+ } from '../data/entities'
17
+ import { assertTenantScope, type CustomersAiToolDefinition, type CustomersToolContext } from './types'
18
+
19
+ function resolveEm(ctx: CustomersToolContext): EntityManager {
20
+ return ctx.container.resolve<EntityManager>('em')
21
+ }
22
+
23
+ function buildScope(ctx: CustomersToolContext, tenantId: string) {
24
+ return { tenantId, organizationId: ctx.organizationId }
25
+ }
26
+
27
+ const getSettingsInput = z.object({}).passthrough()
28
+
29
+ const getSettingsTool: CustomersAiToolDefinition = {
30
+ name: 'customers.get_settings',
31
+ displayName: 'Get customers module settings',
32
+ description:
33
+ 'Return the customers module settings for the caller scope: pipelines, pipeline stages, dictionaries (grouped by kind), and address format.',
34
+ inputSchema: getSettingsInput,
35
+ requiredFeatures: ['customers.settings.manage'],
36
+ tags: ['read', 'customers'],
37
+ handler: async (_rawInput, ctx) => {
38
+ const { tenantId } = assertTenantScope(ctx)
39
+ const em = resolveEm(ctx)
40
+ const where: Record<string, unknown> = { tenantId }
41
+ if (ctx.organizationId) where.organizationId = ctx.organizationId
42
+ const [pipelines, stages, dictionaryEntries, settings] = await Promise.all([
43
+ findWithDecryption<CustomerPipeline>(
44
+ em,
45
+ CustomerPipeline,
46
+ where as any,
47
+ { orderBy: { createdAt: 'asc' } as any } as any,
48
+ buildScope(ctx, tenantId),
49
+ ),
50
+ findWithDecryption<CustomerPipelineStage>(
51
+ em,
52
+ CustomerPipelineStage,
53
+ where as any,
54
+ { orderBy: { pipelineId: 'asc', order: 'asc' } as any } as any,
55
+ buildScope(ctx, tenantId),
56
+ ),
57
+ findWithDecryption<CustomerDictionaryEntry>(
58
+ em,
59
+ CustomerDictionaryEntry,
60
+ where as any,
61
+ { orderBy: { kind: 'asc', label: 'asc' } as any } as any,
62
+ buildScope(ctx, tenantId),
63
+ ),
64
+ ctx.organizationId
65
+ ? findOneWithDecryption<CustomerSettings>(
66
+ em,
67
+ CustomerSettings,
68
+ { tenantId, organizationId: ctx.organizationId } as any,
69
+ undefined,
70
+ buildScope(ctx, tenantId),
71
+ )
72
+ : null,
73
+ ])
74
+ const pipelineRows = pipelines.filter((row) => row.tenantId === tenantId)
75
+ const stageRows = stages.filter((row) => row.tenantId === tenantId)
76
+ const dictionaryRows = dictionaryEntries.filter((row) => row.tenantId === tenantId)
77
+ const dictionaries: Record<string, Array<{
78
+ id: string
79
+ value: string
80
+ label: string
81
+ normalizedValue: string
82
+ color: string | null
83
+ icon: string | null
84
+ }>> = {}
85
+ for (const row of dictionaryRows) {
86
+ const bucket = dictionaries[row.kind] ?? (dictionaries[row.kind] = [])
87
+ bucket.push({
88
+ id: row.id,
89
+ value: row.value,
90
+ label: row.label,
91
+ normalizedValue: row.normalizedValue,
92
+ color: row.color ?? null,
93
+ icon: row.icon ?? null,
94
+ })
95
+ }
96
+ return {
97
+ pipelines: pipelineRows.map((row) => ({
98
+ id: row.id,
99
+ name: row.name,
100
+ isDefault: !!row.isDefault,
101
+ organizationId: row.organizationId ?? null,
102
+ tenantId: row.tenantId ?? null,
103
+ createdAt: row.createdAt ? new Date(row.createdAt).toISOString() : null,
104
+ })),
105
+ pipelineStages: stageRows.map((row) => ({
106
+ id: row.id,
107
+ pipelineId: row.pipelineId,
108
+ label: row.label,
109
+ order: row.order,
110
+ organizationId: row.organizationId ?? null,
111
+ tenantId: row.tenantId ?? null,
112
+ })),
113
+ dictionaries,
114
+ addressFormat: settings?.addressFormat ?? 'line_first',
115
+ }
116
+ },
117
+ }
118
+
119
+ export const settingsAiTools: CustomersAiToolDefinition[] = [getSettingsTool]
120
+
121
+ export default settingsAiTools
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Local AI tool shape for the customers module (Phase 1 WS-C, Step 3.9).
3
+ *
4
+ * The customers module declares its read-only tool pack directly as plain
5
+ * objects so jest can load it without pulling `@open-mercato/ai-assistant`
6
+ * into the core package's module graph. This mirrors the pattern used by
7
+ * `packages/core/src/modules/inbox_ops/ai-tools.ts`. The shape is a strict
8
+ * subset of `AiToolDefinition` from `@open-mercato/ai-assistant`; the
9
+ * generator walks every module root for a default/aiTools export with this
10
+ * shape.
11
+ */
12
+ import type { z } from 'zod'
13
+ import type { AwilixContainer } from 'awilix'
14
+
15
+ export interface CustomersToolContext {
16
+ tenantId: string | null
17
+ organizationId: string | null
18
+ userId: string | null
19
+ container: AwilixContainer
20
+ userFeatures: string[]
21
+ isSuperAdmin: boolean
22
+ apiKeySecret?: string
23
+ sessionId?: string
24
+ }
25
+
26
+ /**
27
+ * Shape returned by `loadBeforeRecord` on a mutation tool. Mirrors
28
+ * `AiToolLoadBeforeSingleRecord` from `@open-mercato/ai-assistant/lib/types`;
29
+ * the customers module deliberately does not import that package so we keep a
30
+ * local prefix-compatible declaration (same rule as `CustomersAiToolDefinition`).
31
+ */
32
+ export interface CustomersToolLoadBeforeSingleRecord {
33
+ recordId: string
34
+ entityType: string
35
+ recordVersion: string | null
36
+ before: Record<string, unknown>
37
+ }
38
+
39
+ export interface CustomersAiToolDefinition<TInput = unknown, TOutput = unknown> {
40
+ name: string
41
+ displayName?: string
42
+ description: string
43
+ inputSchema: z.ZodType<TInput>
44
+ requiredFeatures?: string[]
45
+ tags?: string[]
46
+ isMutation?: boolean
47
+ /**
48
+ * Marks the mutation as destructive — gates the call through the
49
+ * approval card under `destructive-confirm-required` policy. Accepts a
50
+ * static boolean (whole tool destructive) or a predicate
51
+ * `(input) => boolean` evaluated at every call so a multi-operation
52
+ * tool (e.g. `manage_deal_comment` with create/update/delete) gates
53
+ * only the destructive branches. Default `false`. See
54
+ * `@open-mercato/ai-assistant` framework `AiToolDefinition.isDestructive`
55
+ * for the canonical definition; this surface mirrors the contract for
56
+ * type-safe authoring inside the customers module.
57
+ */
58
+ isDestructive?: boolean | ((input: TInput) => boolean)
59
+ maxCallsPerTurn?: number
60
+ supportsAttachments?: boolean
61
+ handler: (input: TInput, context: CustomersToolContext) => Promise<TOutput>
62
+ loadBeforeRecord?: (
63
+ input: TInput,
64
+ context: CustomersToolContext,
65
+ ) => Promise<CustomersToolLoadBeforeSingleRecord | null>
66
+ }
67
+
68
+ export function assertTenantScope(ctx: CustomersToolContext): {
69
+ tenantId: string
70
+ organizationId: string | null
71
+ } {
72
+ if (!ctx.tenantId) {
73
+ throw new Error('Tenant context is required for customers.* tools')
74
+ }
75
+ return { tenantId: ctx.tenantId, organizationId: ctx.organizationId }
76
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Module-root AI tool contribution for the customers module
3
+ * (Phase 1 WS-C, Step 3.9 — read-only Phase 1 surface).
4
+ *
5
+ * The generator walks every module for a top-level `ai-tools.ts` and takes
6
+ * the default/`aiTools` export as the contribution. This file aggregates the
7
+ * six customers packs (people, companies, deals, activities+tasks,
8
+ * addresses+tags, settings) so they all flow through the existing
9
+ * `ai-tools.generated.ts` pipeline without any generator changes.
10
+ *
11
+ * Mutation tools are deferred to Step 5.13+ under the pending-action contract;
12
+ * every tool here is read-only and enforces tenant + organization scoping via
13
+ * the existing encryption helpers. See
14
+ * `.ai/runs/2026-04-18-ai-framework-unification/step-3.9-checks.md` for the
15
+ * matrix of required features and decisions.
16
+ */
17
+ import peopleAiTools from './ai-tools/people-pack'
18
+ import companiesAiTools from './ai-tools/companies-pack'
19
+ import dealsAiTools from './ai-tools/deals-pack'
20
+ import activitiesTasksAiTools from './ai-tools/activities-tasks-pack'
21
+ import addressesTagsAiTools from './ai-tools/addresses-tags-pack'
22
+ import settingsAiTools from './ai-tools/settings-pack'
23
+ import type { CustomersAiToolDefinition } from './ai-tools/types'
24
+
25
+ export const aiTools: CustomersAiToolDefinition[] = [
26
+ ...peopleAiTools,
27
+ ...companiesAiTools,
28
+ ...dealsAiTools,
29
+ ...activitiesTasksAiTools,
30
+ ...addressesTagsAiTools,
31
+ ...settingsAiTools,
32
+ ]
33
+
34
+ export default aiTools
@@ -80,6 +80,31 @@
80
80
  "customers.ai.actions.translate": "Translate",
81
81
  "customers.ai.comingSoon": "Coming soon",
82
82
  "customers.ai.prefix": "KI:",
83
+ "customers.ai_assistant.agents.account.label": "CRM Assistant",
84
+ "customers.ai_assistant.context.matchingPeople": "{count} contacts in view",
85
+ "customers.ai_assistant.context.selectedPeople": "{count} contacts selected",
86
+ "customers.ai_assistant.dealDetail.sheet.composerPlaceholder": "Frage nach diesem Deal, der Stage, Pipeline...",
87
+ "customers.ai_assistant.dealDetail.sheet.description": "Frage zu diesem Deal. Mit aktiviertem Mutations-Richtlinien-Override kann der Assistent auch eine Stage-Änderung vorschlagen, die Sie bestätigen, bevor etwas gespeichert wird.",
88
+ "customers.ai_assistant.dealDetail.sheet.title": "Kunden-AI-Assistent — Deal",
89
+ "customers.ai_assistant.dealDetail.trigger.ariaLabel": "KI-Assistent für diesen Deal öffnen",
90
+ "customers.ai_assistant.dealDetail.trigger.label": "KI fragen",
91
+ "customers.ai_assistant.dock.subtitle": "Customers",
92
+ "customers.ai_assistant.popover.heading": "AI assistants",
93
+ "customers.ai_assistant.sheet.composerPlaceholder": "Frage nach Personen, Firmen, Deals...",
94
+ "customers.ai_assistant.sheet.description": "Read-only-Assistent. Fragen zu Personen, Firmen, Deals und Aktivitäten im Umfang dieser Liste.",
95
+ "customers.ai_assistant.sheet.dock": "Dock to side",
96
+ "customers.ai_assistant.sheet.selectionPill": "Wirkt auf {count} Ausgewählte",
97
+ "customers.ai_assistant.sheet.title": "Kunden-AI-Assistent",
98
+ "customers.ai_assistant.sheet.welcomeTitle": "CRM Assistant",
99
+ "customers.ai_assistant.suggestions.activityOverview": "Activity overview",
100
+ "customers.ai_assistant.suggestions.findCompanies": "Find related companies",
101
+ "customers.ai_assistant.suggestions.findDeals": "Show deals for selected people",
102
+ "customers.ai_assistant.suggestions.recentDeals": "Show recent deals",
103
+ "customers.ai_assistant.suggestions.searchPeople": "Search for a contact",
104
+ "customers.ai_assistant.suggestions.summarizeSelected": "Summarize selected contacts",
105
+ "customers.ai_assistant.suggestions.topCompanies": "List top companies",
106
+ "customers.ai_assistant.trigger.ariaLabel": "KI-Assistent für Personen öffnen",
107
+ "customers.ai_assistant.trigger.label": "AI",
83
108
  "customers.assignableStaff.loadError": "Teammitglieder konnten nicht geladen werden. Prüfen Sie Ihre Berechtigungen und versuchen Sie es erneut.",
84
109
  "customers.audit.activities.create": "Aktivität erstellen",
85
110
  "customers.audit.activities.delete": "Aktivität löschen",
@@ -80,6 +80,31 @@
80
80
  "customers.ai.actions.translate": "Translate",
81
81
  "customers.ai.comingSoon": "Coming soon",
82
82
  "customers.ai.prefix": "AI:",
83
+ "customers.ai_assistant.agents.account.label": "CRM Assistant",
84
+ "customers.ai_assistant.context.matchingPeople": "{count} contacts in view",
85
+ "customers.ai_assistant.context.selectedPeople": "{count} contacts selected",
86
+ "customers.ai_assistant.dealDetail.sheet.composerPlaceholder": "Ask about this deal, the stage, pipeline...",
87
+ "customers.ai_assistant.dealDetail.sheet.description": "Ask about this deal. With the per-tenant mutation-policy override enabled, the assistant can also propose a stage change that you confirm before anything is saved.",
88
+ "customers.ai_assistant.dealDetail.sheet.title": "Customers AI assistant — deal",
89
+ "customers.ai_assistant.dealDetail.trigger.ariaLabel": "Open AI assistant for this deal",
90
+ "customers.ai_assistant.dealDetail.trigger.label": "Ask AI",
91
+ "customers.ai_assistant.dock.subtitle": "Customers",
92
+ "customers.ai_assistant.popover.heading": "AI assistants",
93
+ "customers.ai_assistant.sheet.composerPlaceholder": "Ask about people, companies, deals...",
94
+ "customers.ai_assistant.sheet.description": "Read-only assistant. Ask about people, companies, deals, and activities scoped to this list.",
95
+ "customers.ai_assistant.sheet.dock": "Dock to side",
96
+ "customers.ai_assistant.sheet.selectionPill": "Acting on {count} selected",
97
+ "customers.ai_assistant.sheet.title": "Customers AI assistant",
98
+ "customers.ai_assistant.sheet.welcomeTitle": "CRM Assistant",
99
+ "customers.ai_assistant.suggestions.activityOverview": "Activity overview",
100
+ "customers.ai_assistant.suggestions.findCompanies": "Find related companies",
101
+ "customers.ai_assistant.suggestions.findDeals": "Show deals for selected people",
102
+ "customers.ai_assistant.suggestions.recentDeals": "Show recent deals",
103
+ "customers.ai_assistant.suggestions.searchPeople": "Search for a contact",
104
+ "customers.ai_assistant.suggestions.summarizeSelected": "Summarize selected contacts",
105
+ "customers.ai_assistant.suggestions.topCompanies": "List top companies",
106
+ "customers.ai_assistant.trigger.ariaLabel": "Open AI assistant for people",
107
+ "customers.ai_assistant.trigger.label": "AI",
83
108
  "customers.assignableStaff.loadError": "Unable to load team members. Check your permissions and try again.",
84
109
  "customers.audit.activities.create": "Create activity",
85
110
  "customers.audit.activities.delete": "Delete activity",