@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,505 @@
1
+ /**
2
+ * `customers.list_deals` + `customers.get_deal` (Phase 1 WS-C, Step 3.9).
3
+ * `customers.update_deal_stage` mutation tool (Phase 3 WS-C, Step 5.13).
4
+ *
5
+ * Phase 3a of `.ai/specs/2026-04-27-ai-tools-api-backed-dry-refactor.md`:
6
+ * `customers.list_deals` is now an API-backed wrapper over
7
+ * `GET /api/customers/deals`. Tool name, schema, requiredFeatures, and output
8
+ * shape are unchanged.
9
+ *
10
+ * Phase 3c of the same spec migrates `customers.get_deal` to the documented
11
+ * aggregate detail route. The handler issues 1 call without `includeRelated`
12
+ * (`GET /customers/deals/<id>`) and 3 bounded calls with `includeRelated`
13
+ * (deal detail + activities + comments by `dealId`). The 3-call cap matches
14
+ * the spec's residual N+1 budget; deeper aggregation can earn a first-class
15
+ * API later without touching the AI surface.
16
+ */
17
+ import type { EntityManager } from '@mikro-orm/postgresql'
18
+ import { z } from 'zod'
19
+ import { defineApiBackedAiTool } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/api-backed-tool'
20
+ import {
21
+ createAiApiOperationRunner,
22
+ type AiApiOperationRequest,
23
+ type AiToolExecutionContext,
24
+ } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-api-operation-runner'
25
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
26
+ import {
27
+ CustomerDeal,
28
+ CustomerPipelineStage,
29
+ } from '../data/entities'
30
+ import {
31
+ assertTenantScope,
32
+ type CustomersAiToolDefinition,
33
+ type CustomersToolContext,
34
+ type CustomersToolLoadBeforeSingleRecord,
35
+ } from './types'
36
+
37
+ function resolveEm(ctx: CustomersToolContext | AiToolExecutionContext): EntityManager {
38
+ return ctx.container.resolve<EntityManager>('em')
39
+ }
40
+
41
+ function buildScope(ctx: CustomersToolContext | AiToolExecutionContext, tenantId: string) {
42
+ return { tenantId, organizationId: ctx.organizationId }
43
+ }
44
+
45
+ const listDealsInput = z
46
+ .object({
47
+ q: z.string().trim().optional().describe('Search text matched against deal title / description. Omit or leave empty to list all.'),
48
+ limit: z.number().int().min(1).max(100).optional().describe('Maximum rows to return (default 50, max 100).'),
49
+ offset: z.number().int().min(0).optional().describe('Number of rows to skip (default 0).'),
50
+ personId: z.string().uuid().optional().describe('Return only deals linked to this person entity id.'),
51
+ companyId: z.string().uuid().optional().describe('Return only deals linked to this company entity id.'),
52
+ pipelineStageId: z.string().uuid().optional().describe('Return only deals at this pipeline stage.'),
53
+ status: z.string().optional().describe('Filter by deal status (e.g. "open", "won", "lost").'),
54
+ })
55
+ .passthrough()
56
+
57
+ type ListDealsInput = z.infer<typeof listDealsInput>
58
+
59
+ type ListDealsApiItem = {
60
+ id?: string
61
+ title?: string | null
62
+ description?: string | null
63
+ status?: string | null
64
+ pipeline_id?: string | null
65
+ pipelineId?: string | null
66
+ pipeline_stage_id?: string | null
67
+ pipelineStageId?: string | null
68
+ value_amount?: string | number | null
69
+ valueAmount?: string | number | null
70
+ value_currency?: string | null
71
+ valueCurrency?: string | null
72
+ probability?: number | null
73
+ owner_user_id?: string | null
74
+ ownerUserId?: string | null
75
+ expected_close_at?: string | null
76
+ expectedCloseAt?: string | null
77
+ source?: string | null
78
+ organization_id?: string | null
79
+ organizationId?: string | null
80
+ tenant_id?: string | null
81
+ tenantId?: string | null
82
+ created_at?: string | null
83
+ createdAt?: string | null
84
+ }
85
+
86
+ type ListDealsApiResponse = {
87
+ items?: ListDealsApiItem[]
88
+ total?: number
89
+ }
90
+
91
+ type ListDealsOutput = {
92
+ items: Array<Record<string, unknown>>
93
+ total: number
94
+ limit: number
95
+ offset: number
96
+ }
97
+
98
+ const listDealsTool = defineApiBackedAiTool<ListDealsInput, ListDealsApiResponse, ListDealsOutput>({
99
+ name: 'customers.list_deals',
100
+ displayName: 'List deals',
101
+ description:
102
+ 'Search / list deals for the caller tenant + organization. Optional filters include linked person / company / pipeline stage.',
103
+ inputSchema: listDealsInput,
104
+ requiredFeatures: ['customers.deals.view'],
105
+ toOperation: (input, ctx) => {
106
+ assertTenantScope(ctx as unknown as CustomersToolContext)
107
+ const limit = input.limit ?? 50
108
+ const offset = input.offset ?? 0
109
+ const page = Math.floor(offset / limit) + 1
110
+
111
+ const query: Record<string, string | number | boolean | null | undefined> = {
112
+ page,
113
+ pageSize: limit,
114
+ }
115
+ if (input.q?.trim()) query.search = input.q.trim()
116
+ if (input.personId) query.personId = input.personId
117
+ if (input.companyId) query.companyId = input.companyId
118
+ if (input.pipelineStageId) query.pipelineStageId = input.pipelineStageId
119
+ if (input.status) query.status = input.status
120
+
121
+ const operation: AiApiOperationRequest = {
122
+ method: 'GET',
123
+ path: '/customers/deals',
124
+ query,
125
+ }
126
+ return operation
127
+ },
128
+ mapResponse: (response, input) => {
129
+ const limit = input.limit ?? 50
130
+ const offset = input.offset ?? 0
131
+ const data = (response.data ?? {}) as ListDealsApiResponse
132
+ const rawItems: ListDealsApiItem[] = Array.isArray(data.items) ? data.items : []
133
+ return {
134
+ items: rawItems.map((row) => {
135
+ const expectedCloseRaw = row.expected_close_at ?? row.expectedCloseAt ?? null
136
+ const expectedCloseAt = expectedCloseRaw ? new Date(String(expectedCloseRaw)).toISOString() : null
137
+ const createdAtRaw = row.created_at ?? row.createdAt ?? null
138
+ const createdAt = createdAtRaw ? new Date(String(createdAtRaw)).toISOString() : null
139
+ return {
140
+ id: row.id,
141
+ title: row.title ?? null,
142
+ description: row.description ?? null,
143
+ status: row.status ?? null,
144
+ pipelineId: row.pipeline_id ?? row.pipelineId ?? null,
145
+ pipelineStageId: row.pipeline_stage_id ?? row.pipelineStageId ?? null,
146
+ valueAmount: row.value_amount ?? row.valueAmount ?? null,
147
+ valueCurrency: row.value_currency ?? row.valueCurrency ?? null,
148
+ probability: row.probability ?? null,
149
+ ownerUserId: row.owner_user_id ?? row.ownerUserId ?? null,
150
+ expectedCloseAt,
151
+ source: row.source ?? null,
152
+ organizationId: row.organization_id ?? row.organizationId ?? null,
153
+ tenantId: row.tenant_id ?? row.tenantId ?? null,
154
+ createdAt,
155
+ }
156
+ }),
157
+ total: typeof data.total === 'number' ? data.total : 0,
158
+ limit,
159
+ offset,
160
+ }
161
+ },
162
+ }) as unknown as CustomersAiToolDefinition
163
+
164
+ const getDealInput = z.object({
165
+ dealId: z.string().uuid().describe('Deal id (UUID).'),
166
+ includeRelated: z
167
+ .boolean()
168
+ .optional()
169
+ .describe('When true, include notes, activities, linked people and companies (each capped at 100).'),
170
+ })
171
+
172
+ type GetDealInput = z.infer<typeof getDealInput>
173
+
174
+ function toIsoDeal(value: unknown): string | null {
175
+ if (!value) return null
176
+ const dt = value instanceof Date ? value : new Date(String(value))
177
+ if (Number.isNaN(dt.getTime())) return null
178
+ return dt.toISOString()
179
+ }
180
+
181
+ const getDealTool: CustomersAiToolDefinition = {
182
+ name: 'customers.get_deal',
183
+ displayName: 'Get deal',
184
+ description:
185
+ 'Fetch a deal by id with fields and (optionally) notes, activities, linked people, and linked companies. Returns { found: false } when outside tenant/org scope.',
186
+ inputSchema: getDealInput,
187
+ requiredFeatures: ['customers.deals.view'],
188
+ tags: ['read', 'customers'],
189
+ handler: async (rawInput, ctx) => {
190
+ const { tenantId: _tenantId } = assertTenantScope(ctx)
191
+ void _tenantId
192
+ const input: GetDealInput = getDealInput.parse(rawInput)
193
+ const includeRelated = !!input.includeRelated
194
+ const runner = createAiApiOperationRunner(ctx as unknown as AiToolExecutionContext)
195
+
196
+ const detailResponse = await runner.run<Record<string, unknown>>({
197
+ method: 'GET',
198
+ path: `/customers/deals/${input.dealId}`,
199
+ })
200
+ if (!detailResponse.success) {
201
+ if (detailResponse.statusCode === 404 || detailResponse.statusCode === 403) {
202
+ return { found: false as const, dealId: input.dealId }
203
+ }
204
+ throw new Error(detailResponse.error ?? `Failed to fetch deal ${input.dealId}`)
205
+ }
206
+ const detail = (detailResponse.data ?? {}) as Record<string, unknown>
207
+ const dealRow = (detail.deal ?? null) as Record<string, unknown> | null
208
+ if (!dealRow) {
209
+ return { found: false as const, dealId: input.dealId }
210
+ }
211
+ const customFields = (detail.customFields ?? {}) as Record<string, unknown>
212
+ const peopleRows = Array.isArray(detail.people) ? (detail.people as Array<Record<string, unknown>>) : []
213
+ const companiesRows = Array.isArray(detail.companies)
214
+ ? (detail.companies as Array<Record<string, unknown>>)
215
+ : []
216
+
217
+ let related: Record<string, unknown> | null = null
218
+ if (includeRelated) {
219
+ const [activitiesResponse, commentsResponse] = await Promise.all([
220
+ runner.run<{ items?: Array<Record<string, unknown>>; total?: number }>({
221
+ method: 'GET',
222
+ path: '/customers/activities',
223
+ query: { dealId: input.dealId, page: 1, pageSize: 100, sortField: 'occurredAt', sortDir: 'desc' },
224
+ }),
225
+ runner.run<{ items?: Array<Record<string, unknown>>; total?: number }>({
226
+ method: 'GET',
227
+ path: '/customers/comments',
228
+ query: { dealId: input.dealId, page: 1, pageSize: 100 },
229
+ }),
230
+ ])
231
+ const activities =
232
+ activitiesResponse.success && Array.isArray(activitiesResponse.data?.items)
233
+ ? (activitiesResponse.data!.items as Array<Record<string, unknown>>)
234
+ : []
235
+ const comments =
236
+ commentsResponse.success && Array.isArray(commentsResponse.data?.items)
237
+ ? (commentsResponse.data!.items as Array<Record<string, unknown>>)
238
+ : []
239
+
240
+ related = {
241
+ activities: activities.map((activity) => ({
242
+ id: activity.id,
243
+ activityType: activity.activityType ?? activity.activity_type ?? null,
244
+ subject: activity.subject ?? null,
245
+ body: activity.body ?? null,
246
+ occurredAt: toIsoDeal(activity.occurredAt ?? activity.occurred_at),
247
+ createdAt: toIsoDeal(activity.createdAt ?? activity.created_at),
248
+ })),
249
+ notes: comments.map((comment) => ({
250
+ id: comment.id,
251
+ body: comment.body,
252
+ authorUserId: comment.authorUserId ?? comment.author_user_id ?? null,
253
+ createdAt: toIsoDeal(comment.createdAt ?? comment.created_at),
254
+ })),
255
+ people: peopleRows
256
+ .map((person) => {
257
+ if (!person || typeof person !== 'object') return null
258
+ const id = typeof person.id === 'string' ? person.id : null
259
+ if (!id) return null
260
+ const subtitle = typeof person.subtitle === 'string' ? person.subtitle : null
261
+ const label = typeof person.label === 'string' ? person.label : ''
262
+ const entry: {
263
+ id: string
264
+ displayName: string
265
+ primaryEmail: string | null
266
+ primaryPhone: string | null
267
+ participantRole: string | null
268
+ } = {
269
+ id,
270
+ displayName: label,
271
+ primaryEmail: subtitle && subtitle.includes('@') ? subtitle : null,
272
+ primaryPhone: subtitle && !subtitle.includes('@') ? subtitle : null,
273
+ participantRole: null as string | null,
274
+ }
275
+ return entry
276
+ })
277
+ .filter(
278
+ (value): value is {
279
+ id: string
280
+ displayName: string
281
+ primaryEmail: string | null
282
+ primaryPhone: string | null
283
+ participantRole: string | null
284
+ } => value !== null,
285
+ ),
286
+ companies: companiesRows
287
+ .map((company) => {
288
+ if (!company || typeof company !== 'object') return null
289
+ const id = typeof company.id === 'string' ? company.id : null
290
+ if (!id) return null
291
+ const label = typeof company.label === 'string' ? company.label : ''
292
+ const entry: {
293
+ id: string
294
+ displayName: string
295
+ primaryEmail: string | null
296
+ primaryPhone: string | null
297
+ } = {
298
+ id,
299
+ displayName: label,
300
+ primaryEmail: null as string | null,
301
+ primaryPhone: null as string | null,
302
+ }
303
+ return entry
304
+ })
305
+ .filter(
306
+ (value): value is {
307
+ id: string
308
+ displayName: string
309
+ primaryEmail: string | null
310
+ primaryPhone: string | null
311
+ } => value !== null,
312
+ ),
313
+ }
314
+ }
315
+
316
+ return {
317
+ found: true as const,
318
+ deal: {
319
+ id: dealRow.id,
320
+ title: typeof dealRow.title === 'string' ? dealRow.title : '',
321
+ description: dealRow.description ?? null,
322
+ status: dealRow.status ?? null,
323
+ pipelineId: dealRow.pipelineId ?? null,
324
+ pipelineStageId: dealRow.pipelineStageId ?? null,
325
+ valueAmount: dealRow.valueAmount ?? null,
326
+ valueCurrency: dealRow.valueCurrency ?? null,
327
+ probability: dealRow.probability ?? null,
328
+ ownerUserId: dealRow.ownerUserId ?? null,
329
+ expectedCloseAt: toIsoDeal(dealRow.expectedCloseAt),
330
+ source: dealRow.source ?? null,
331
+ organizationId: dealRow.organizationId ?? null,
332
+ tenantId: dealRow.tenantId ?? null,
333
+ createdAt: toIsoDeal(dealRow.createdAt),
334
+ updatedAt: toIsoDeal(dealRow.updatedAt),
335
+ },
336
+ customFields,
337
+ related,
338
+ }
339
+ },
340
+ }
341
+
342
+ /**
343
+ * Mutation tool: move a deal to a different pipeline stage. Step 5.13 — first
344
+ * mutation-capable flow on the pending-action contract.
345
+ *
346
+ * Accepts either `toPipelineStageId` (UUID — preferred, tenant-scoped stage
347
+ * record) or `toStage` (free-form string that maps to `CustomerDeal.status`
348
+ * for pipeline roots like `open`/`won`/`lost`). Exactly one must be provided.
349
+ *
350
+ * The handler delegates to the existing `customers.deals.update` command so
351
+ * all side effects (audit log, `customers.deal.updated` event, query index
352
+ * refresh, notifications) stay identical to a direct API write.
353
+ */
354
+ // LLMs frequently emit `""` for "not provided" — coerce blanks (and surrounding
355
+ // whitespace) to `undefined` BEFORE the per-field validators run so the
356
+ // `.uuid()` check on `toPipelineStageId` does not blow up on an empty string
357
+ // the caller actually meant as "skip this field".
358
+ const blankToUndefined = (value: unknown): unknown => {
359
+ if (typeof value !== 'string') return value
360
+ const trimmed = value.trim()
361
+ return trimmed.length === 0 ? undefined : trimmed
362
+ }
363
+
364
+ const updateDealStageInput = z
365
+ .object({
366
+ dealId: z.string().uuid().describe('Deal id (UUID) to update.'),
367
+ toPipelineStageId: z
368
+ .preprocess(blankToUndefined, z.string().uuid().optional())
369
+ .describe('Target pipeline stage id (UUID). Preferred — tenant-scoped stage record.'),
370
+ toStage: z
371
+ .preprocess(blankToUndefined, z.string().min(1).max(50).optional())
372
+ .describe(
373
+ 'Target status slug (e.g. "open", "won", "lost"). Used when the deal does not belong to a managed pipeline.',
374
+ ),
375
+ })
376
+ .refine(
377
+ (value) => Boolean(value.toPipelineStageId) !== Boolean(value.toStage),
378
+ {
379
+ message: 'Provide exactly one of toPipelineStageId or toStage.',
380
+ path: ['toPipelineStageId'],
381
+ },
382
+ )
383
+
384
+ type UpdateDealStageInput = z.infer<typeof updateDealStageInput>
385
+
386
+ function recordVersionFromUpdatedAt(updatedAt: Date | null | undefined): string | null {
387
+ if (!updatedAt) return null
388
+ const value = updatedAt instanceof Date ? updatedAt : new Date(updatedAt)
389
+ if (Number.isNaN(value.getTime())) return null
390
+ return value.toISOString()
391
+ }
392
+
393
+ async function loadDealWithStage(
394
+ em: EntityManager,
395
+ ctx: CustomersToolContext,
396
+ tenantId: string,
397
+ dealId: string,
398
+ ): Promise<CustomerDeal | null> {
399
+ const where: Record<string, unknown> = { id: dealId, tenantId, deletedAt: null }
400
+ if (ctx.organizationId) where.organizationId = ctx.organizationId
401
+ const deal = await findOneWithDecryption<CustomerDeal>(
402
+ em,
403
+ CustomerDeal,
404
+ where as any,
405
+ undefined,
406
+ buildScope(ctx, tenantId),
407
+ )
408
+ if (!deal || deal.tenantId !== tenantId) return null
409
+ if (ctx.organizationId && deal.organizationId !== ctx.organizationId) return null
410
+ return deal
411
+ }
412
+
413
+ const updateDealStageTool: CustomersAiToolDefinition = {
414
+ name: 'customers.update_deal_stage',
415
+ displayName: 'Update deal stage',
416
+ description:
417
+ 'Move a deal to a different pipeline stage (by stage id) or change its top-level status (e.g. "open", "won", "lost"). Mutation tool — flows through the AI pending-action approval gate.',
418
+ inputSchema: updateDealStageInput as z.ZodType<unknown>,
419
+ requiredFeatures: ['customers.deals.manage'],
420
+ tags: ['write', 'customers'],
421
+ isMutation: true,
422
+ loadBeforeRecord: async (rawInput, ctx): Promise<CustomersToolLoadBeforeSingleRecord | null> => {
423
+ const { tenantId } = assertTenantScope(ctx)
424
+ const input: UpdateDealStageInput = updateDealStageInput.parse(rawInput)
425
+ const em = resolveEm(ctx)
426
+ const deal = await loadDealWithStage(em, ctx, tenantId, input.dealId)
427
+ if (!deal) return null
428
+ return {
429
+ recordId: deal.id,
430
+ entityType: 'customers.deal',
431
+ recordVersion: recordVersionFromUpdatedAt(deal.updatedAt),
432
+ before: {
433
+ status: deal.status ?? null,
434
+ pipelineStage: deal.pipelineStage ?? null,
435
+ pipelineStageId: deal.pipelineStageId ?? null,
436
+ },
437
+ }
438
+ },
439
+ handler: async (rawInput, ctx) => {
440
+ const { tenantId } = assertTenantScope(ctx)
441
+ const input: UpdateDealStageInput = updateDealStageInput.parse(rawInput)
442
+ const em = resolveEm(ctx)
443
+ const deal = await loadDealWithStage(em, ctx, tenantId, input.dealId)
444
+ if (!deal) {
445
+ throw new Error(`Deal "${input.dealId}" is not accessible to the caller.`)
446
+ }
447
+ const organizationId = deal.organizationId
448
+ if (!organizationId) {
449
+ throw new Error(`Deal "${input.dealId}" has no organization scope.`)
450
+ }
451
+
452
+ const before = {
453
+ status: deal.status ?? null,
454
+ pipelineStage: deal.pipelineStage ?? null,
455
+ pipelineStageId: deal.pipelineStageId ?? null,
456
+ }
457
+
458
+ const body: Record<string, unknown> = {
459
+ id: deal.id,
460
+ tenantId,
461
+ organizationId,
462
+ }
463
+ if (input.toPipelineStageId) {
464
+ const stage = await em.findOne(CustomerPipelineStage, {
465
+ id: input.toPipelineStageId,
466
+ tenantId,
467
+ organizationId,
468
+ })
469
+ if (!stage) {
470
+ throw new Error('Pipeline stage not found.')
471
+ }
472
+ body.pipelineStageId = input.toPipelineStageId
473
+ } else if (input.toStage) {
474
+ body.status = input.toStage
475
+ }
476
+
477
+ const runner = createAiApiOperationRunner(ctx as unknown as AiToolExecutionContext)
478
+ const response = await runner.run({
479
+ method: 'PUT',
480
+ path: '/customers/deals',
481
+ body,
482
+ })
483
+ if (!response.success) {
484
+ throw new Error(response.error ?? `Failed to update deal "${deal.id}"`)
485
+ }
486
+
487
+ const after = await loadDealWithStage(em, ctx, tenantId, deal.id)
488
+ return {
489
+ recordId: deal.id,
490
+ commandName: 'customers.deals.update',
491
+ before,
492
+ after: after
493
+ ? {
494
+ status: after.status ?? null,
495
+ pipelineStage: after.pipelineStage ?? null,
496
+ pipelineStageId: after.pipelineStageId ?? null,
497
+ }
498
+ : null,
499
+ }
500
+ },
501
+ }
502
+
503
+ export const dealsAiTools: CustomersAiToolDefinition[] = [listDealsTool, getDealTool, updateDealStageTool]
504
+
505
+ export default dealsAiTools