@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,761 @@
1
+ /**
2
+ * Catalog D18 mutation tool pack (Phase 3 WS-C, Step 5.14).
3
+ *
4
+ * Ships the four mutation tools the `catalog.merchandising_assistant`
5
+ * (Step 4.9) whitelists under the pending-action approval contract:
6
+ *
7
+ * 1. `catalog.update_product` — single-record write (isBulk=false).
8
+ * 2. `catalog.bulk_update_products` — batch write (isBulk=true). One
9
+ * pending action per batch; per-record versions carried in
10
+ * `records[]` so Step 5.8's confirm route can reject stale rows
11
+ * individually without aborting the batch.
12
+ * 3. `catalog.apply_attribute_extraction` — batch write that persists
13
+ * the structured output of `catalog.extract_attributes_from_description`
14
+ * (Step 3.12) against the CURRENT attribute schema. Schema drift is
15
+ * caught at two points: (a) the tool's pre-submit validator rejects
16
+ * attribute keys that do not resolve to a custom-field definition,
17
+ * and (b) the Step 5.8 re-check loop calls `loadBeforeRecords` again
18
+ * before confirm.
19
+ * 4. `catalog.update_product_media_descriptions` — batch write that
20
+ * updates altText / caption on product media attachments via the
21
+ * attachment's `storageMetadata` envelope. No attachments command
22
+ * exists yet, so the handler performs a guarded direct EM flush.
23
+ * The writes are scalar only (no intermediate queries on the same EM)
24
+ * so `em.flush()` in a single phase is safe and matches the spec's
25
+ * "single pending-action per batch" contract.
26
+ *
27
+ * All four tools:
28
+ * - Are tenant + organization scoped through `findOneWithDecryption` /
29
+ * `findWithDecryption` before any write.
30
+ * - Use `z.object(...).strict()` on their input schemas (spec §7 rule:
31
+ * reject hallucinated attribute names / unknown fields).
32
+ * - Preserve per-record result shape when only some records fail:
33
+ * handler returns `{ records: [{ recordId, status, before, after, error? }], failedRecordIds }`
34
+ * so Step 5.8's executor can populate `failedRecords[]` on the
35
+ * `AiPendingAction` row without the caller treating a partial failure
36
+ * as a batch failure.
37
+ * - Delegate the authoritative write to an existing command
38
+ * (`catalog.products.update`) where possible, so all downstream side
39
+ * effects (audit log, `catalog.product.updated` event, query index
40
+ * refresh, notifications) stay identical to a direct API write. The
41
+ * media-description tool is the one exception: it updates
42
+ * `attachments.storage_metadata` directly because no attachments
43
+ * command exists. Tenant + organization scope are re-checked inside
44
+ * the direct write.
45
+ *
46
+ * BC: additive only. `CatalogAiToolDefinition` grows optional
47
+ * `loadBeforeRecord` / `loadBeforeRecords` / `isBulk` fields that default
48
+ * to undefined on every pre-5.14 tool. No new feature ids, no DB
49
+ * migrations, no event-id changes. Mutation-policy override at the
50
+ * tenant level is still the only lever that unlocks writes — agent
51
+ * `readOnly: true` stays unchanged (Step 5.13 discipline).
52
+ */
53
+ import type { EntityManager } from '@mikro-orm/postgresql'
54
+ import { z } from 'zod'
55
+ import {
56
+ findOneWithDecryption,
57
+ findWithDecryption,
58
+ } from '@open-mercato/shared/lib/encryption/find'
59
+ import { loadCustomFieldDefinitionIndex } from '@open-mercato/shared/lib/crud/custom-fields'
60
+ import { E } from '#generated/entities.ids.generated'
61
+ import {
62
+ createAiApiOperationRunner,
63
+ type AiToolExecutionContext,
64
+ } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-api-operation-runner'
65
+ import { CatalogProduct, CatalogProductPrice } from '../data/entities'
66
+ import { Attachment } from '@open-mercato/core/modules/attachments/data/entities'
67
+ import {
68
+ assertTenantScope,
69
+ type CatalogAiToolDefinition,
70
+ type CatalogToolContext,
71
+ type CatalogToolLoadBeforeRecord,
72
+ type CatalogToolLoadBeforeSingleRecord,
73
+ } from './types'
74
+
75
+ function resolveEm(ctx: CatalogToolContext): EntityManager {
76
+ return ctx.container.resolve<EntityManager>('em')
77
+ }
78
+
79
+ function buildScope(ctx: CatalogToolContext, tenantId: string) {
80
+ return { tenantId, organizationId: ctx.organizationId }
81
+ }
82
+
83
+ function recordVersionFromUpdatedAt(updatedAt: Date | null | undefined): string | null {
84
+ if (!updatedAt) return null
85
+ const value = updatedAt instanceof Date ? updatedAt : new Date(updatedAt)
86
+ if (Number.isNaN(value.getTime())) return null
87
+ return value.toISOString()
88
+ }
89
+
90
+ async function loadProductForScope(
91
+ em: EntityManager,
92
+ ctx: CatalogToolContext,
93
+ tenantId: string,
94
+ productId: string,
95
+ ): Promise<CatalogProduct | null> {
96
+ const where: Record<string, unknown> = { id: productId, tenantId, deletedAt: null }
97
+ if (ctx.organizationId) where.organizationId = ctx.organizationId
98
+ const row = await findOneWithDecryption<CatalogProduct>(
99
+ em,
100
+ CatalogProduct,
101
+ where as any,
102
+ undefined,
103
+ buildScope(ctx, tenantId),
104
+ )
105
+ if (!row || row.tenantId !== tenantId) return null
106
+ if (ctx.organizationId && row.organizationId !== ctx.organizationId) return null
107
+ return row
108
+ }
109
+
110
+ function productSnapshot(row: CatalogProduct): Record<string, unknown> {
111
+ return {
112
+ title: row.title ?? null,
113
+ subtitle: row.subtitle ?? null,
114
+ description: row.description ?? null,
115
+ sku: row.sku ?? null,
116
+ handle: row.handle ?? null,
117
+ isActive: row.isActive ?? null,
118
+ primaryCurrencyCode: row.primaryCurrencyCode ?? null,
119
+ }
120
+ }
121
+
122
+ function productLabel(row: CatalogProduct): string {
123
+ if (row.title && row.title.trim().length) return row.title
124
+ if (row.sku && row.sku.trim().length) return row.sku
125
+ return row.id
126
+ }
127
+
128
+ /* -------------------------------------------------------------------------- */
129
+ /* Price pre-submit validation */
130
+ /* -------------------------------------------------------------------------- */
131
+
132
+ const pricePatchSchema = z
133
+ .object({
134
+ priceKindId: z.string().uuid().optional(),
135
+ currencyCode: z.string().trim().min(3).max(8).optional(),
136
+ amount: z.number().nonnegative(),
137
+ })
138
+ .strict()
139
+
140
+ async function validateProductPriceScope(
141
+ em: EntityManager,
142
+ tenantId: string,
143
+ organizationId: string,
144
+ productId: string,
145
+ priceInput: z.infer<typeof pricePatchSchema>,
146
+ ): Promise<{ ok: true } | { ok: false; code: string; message: string }> {
147
+ if (priceInput.currencyCode) {
148
+ const candidates = await findWithDecryption<CatalogProductPrice>(
149
+ em,
150
+ CatalogProductPrice,
151
+ { tenantId, product: productId } as any,
152
+ undefined,
153
+ { tenantId, organizationId },
154
+ )
155
+ const seen = new Set<string>(candidates.map((entry) => (entry.currencyCode ?? '').toUpperCase()))
156
+ if (seen.size > 0 && !seen.has(priceInput.currencyCode.toUpperCase())) {
157
+ return {
158
+ ok: false,
159
+ code: 'currency_out_of_scope',
160
+ message: `Currency "${priceInput.currencyCode}" is not configured for product ${productId}.`,
161
+ }
162
+ }
163
+ }
164
+ return { ok: true }
165
+ }
166
+
167
+ /* -------------------------------------------------------------------------- */
168
+ /* catalog.update_product (single-record) */
169
+ /* -------------------------------------------------------------------------- */
170
+
171
+ const updateProductInput = z
172
+ .object({
173
+ productId: z.string().uuid().describe('Catalog product id (UUID).'),
174
+ title: z.string().trim().min(1).max(255).optional(),
175
+ subtitle: z.string().trim().max(255).nullable().optional(),
176
+ description: z.string().trim().max(4000).nullable().optional(),
177
+ isActive: z.boolean().optional(),
178
+ price: pricePatchSchema.optional().describe('Optional price adjustment; must match an existing currency for the product.'),
179
+ })
180
+ .strict()
181
+
182
+ type UpdateProductInput = z.infer<typeof updateProductInput>
183
+
184
+ const updateProductTool: CatalogAiToolDefinition = {
185
+ name: 'catalog.update_product',
186
+ displayName: 'Update product',
187
+ description:
188
+ 'Update a single catalog product (title, subtitle, description, isActive, optional price). Routes through the AI pending-action approval gate.',
189
+ inputSchema: updateProductInput as z.ZodType<unknown>,
190
+ requiredFeatures: ['catalog.products.manage'],
191
+ tags: ['write', 'catalog', 'merchandising'],
192
+ isMutation: true,
193
+ isBulk: false,
194
+ loadBeforeRecord: async (
195
+ rawInput,
196
+ ctx,
197
+ ): Promise<CatalogToolLoadBeforeSingleRecord | null> => {
198
+ const { tenantId } = assertTenantScope(ctx)
199
+ const input: UpdateProductInput = updateProductInput.parse(rawInput)
200
+ const em = resolveEm(ctx)
201
+ const row = await loadProductForScope(em, ctx, tenantId, input.productId)
202
+ if (!row) return null
203
+ return {
204
+ recordId: row.id,
205
+ entityType: 'catalog.product',
206
+ recordVersion: recordVersionFromUpdatedAt(row.updatedAt),
207
+ before: productSnapshot(row),
208
+ }
209
+ },
210
+ handler: async (rawInput, ctx) => {
211
+ const { tenantId } = assertTenantScope(ctx)
212
+ const input: UpdateProductInput = updateProductInput.parse(rawInput)
213
+ const em = resolveEm(ctx)
214
+ const row = await loadProductForScope(em, ctx, tenantId, input.productId)
215
+ if (!row) {
216
+ throw new Error(`Product "${input.productId}" is not accessible to the caller.`)
217
+ }
218
+ const organizationId = row.organizationId
219
+ const before = productSnapshot(row)
220
+
221
+ if (input.price) {
222
+ const check = await validateProductPriceScope(
223
+ em,
224
+ tenantId,
225
+ organizationId,
226
+ row.id,
227
+ input.price,
228
+ )
229
+ if (!check.ok) {
230
+ const error = new Error(check.message) as Error & { code?: string }
231
+ error.code = check.code
232
+ throw error
233
+ }
234
+ }
235
+
236
+ const body: Record<string, unknown> = {
237
+ id: row.id,
238
+ tenantId,
239
+ organizationId,
240
+ }
241
+ if (input.title !== undefined) body.title = input.title
242
+ if (input.subtitle !== undefined) body.subtitle = input.subtitle
243
+ if (input.description !== undefined) body.description = input.description
244
+ if (input.isActive !== undefined) body.isActive = input.isActive
245
+
246
+ const runner = createAiApiOperationRunner(ctx as unknown as AiToolExecutionContext)
247
+ const response = await runner.run({
248
+ method: 'PUT',
249
+ path: '/catalog/products',
250
+ body,
251
+ })
252
+ if (!response.success) {
253
+ throw new Error(response.error ?? `Failed to update product "${row.id}"`)
254
+ }
255
+
256
+ const after = await loadProductForScope(em, ctx, tenantId, row.id)
257
+ return {
258
+ recordId: row.id,
259
+ commandName: 'catalog.products.update',
260
+ before,
261
+ after: after ? productSnapshot(after) : null,
262
+ }
263
+ },
264
+ }
265
+
266
+ /* -------------------------------------------------------------------------- */
267
+ /* catalog.bulk_update_products (batch) */
268
+ /* -------------------------------------------------------------------------- */
269
+
270
+ const bulkUpdateProductRecordSchema = z
271
+ .object({
272
+ recordId: z.string().uuid(),
273
+ title: z.string().trim().min(1).max(255).optional(),
274
+ subtitle: z.string().trim().max(255).nullable().optional(),
275
+ description: z.string().trim().max(4000).nullable().optional(),
276
+ isActive: z.boolean().optional(),
277
+ price: pricePatchSchema.optional(),
278
+ })
279
+ .strict()
280
+
281
+ const bulkUpdateProductsInput = z
282
+ .object({
283
+ records: z
284
+ .array(bulkUpdateProductRecordSchema)
285
+ .min(1)
286
+ .max(50)
287
+ .describe('One entry per product to update. Max 50 rows per batch.'),
288
+ })
289
+ .strict()
290
+
291
+ type BulkUpdateProductsInput = z.infer<typeof bulkUpdateProductsInput>
292
+ type BulkUpdateProductPatch = z.infer<typeof bulkUpdateProductRecordSchema>
293
+
294
+ type BulkRecordResult = {
295
+ recordId: string
296
+ status: 'updated' | 'skipped' | 'failed'
297
+ before: Record<string, unknown> | null
298
+ after: Record<string, unknown> | null
299
+ error?: { code: string; message: string }
300
+ }
301
+
302
+ const bulkUpdateProductsTool: CatalogAiToolDefinition = {
303
+ name: 'catalog.bulk_update_products',
304
+ displayName: 'Bulk update products',
305
+ description:
306
+ 'Update several catalog products in a single approval. Emits ONE pending action whose records[] contains one diff per product. Stale-version or cross-tenant rows are collected in failedRecords[] without aborting the remaining writes.',
307
+ inputSchema: bulkUpdateProductsInput as z.ZodType<unknown>,
308
+ requiredFeatures: ['catalog.products.manage'],
309
+ tags: ['write', 'catalog', 'merchandising', 'bulk'],
310
+ isMutation: true,
311
+ isBulk: true,
312
+ loadBeforeRecords: async (rawInput, ctx): Promise<CatalogToolLoadBeforeRecord[]> => {
313
+ const { tenantId } = assertTenantScope(ctx)
314
+ const input: BulkUpdateProductsInput = bulkUpdateProductsInput.parse(rawInput)
315
+ const em = resolveEm(ctx)
316
+ const rows: CatalogToolLoadBeforeRecord[] = []
317
+ for (const entry of input.records) {
318
+ const product = await loadProductForScope(em, ctx, tenantId, entry.recordId)
319
+ if (!product) continue
320
+ rows.push({
321
+ recordId: product.id,
322
+ entityType: 'catalog.product',
323
+ label: productLabel(product),
324
+ recordVersion: recordVersionFromUpdatedAt(product.updatedAt),
325
+ before: productSnapshot(product),
326
+ })
327
+ }
328
+ return rows
329
+ },
330
+ handler: async (rawInput, ctx) => {
331
+ const { tenantId } = assertTenantScope(ctx)
332
+ const input: BulkUpdateProductsInput = bulkUpdateProductsInput.parse(rawInput)
333
+ const em = resolveEm(ctx)
334
+ const runner = createAiApiOperationRunner(ctx as unknown as AiToolExecutionContext)
335
+ const results: BulkRecordResult[] = []
336
+ const failedRecordIds: string[] = []
337
+ for (const entry of input.records) {
338
+ const product = await loadProductForScope(em, ctx, tenantId, entry.recordId)
339
+ if (!product) {
340
+ failedRecordIds.push(entry.recordId)
341
+ results.push({
342
+ recordId: entry.recordId,
343
+ status: 'skipped',
344
+ before: null,
345
+ after: null,
346
+ error: { code: 'record_not_found', message: 'Product is not accessible to the caller.' },
347
+ })
348
+ continue
349
+ }
350
+ const organizationId = product.organizationId
351
+ const before = productSnapshot(product)
352
+ if (entry.price) {
353
+ const check = await validateProductPriceScope(em, tenantId, organizationId, product.id, entry.price)
354
+ if (!check.ok) {
355
+ failedRecordIds.push(product.id)
356
+ results.push({
357
+ recordId: product.id,
358
+ status: 'failed',
359
+ before,
360
+ after: null,
361
+ error: { code: check.code, message: check.message },
362
+ })
363
+ continue
364
+ }
365
+ }
366
+ const body: Record<string, unknown> = {
367
+ id: product.id,
368
+ tenantId,
369
+ organizationId,
370
+ }
371
+ if (entry.title !== undefined) body.title = entry.title
372
+ if (entry.subtitle !== undefined) body.subtitle = entry.subtitle
373
+ if (entry.description !== undefined) body.description = entry.description
374
+ if (entry.isActive !== undefined) body.isActive = entry.isActive
375
+ try {
376
+ const response = await runner.run({
377
+ method: 'PUT',
378
+ path: '/catalog/products',
379
+ body,
380
+ })
381
+ if (!response.success) {
382
+ const code =
383
+ typeof (response.details as { code?: unknown } | undefined)?.code === 'string'
384
+ ? ((response.details as { code: string }).code)
385
+ : 'command_failed'
386
+ failedRecordIds.push(product.id)
387
+ results.push({
388
+ recordId: product.id,
389
+ status: 'failed',
390
+ before,
391
+ after: null,
392
+ error: { code, message: response.error ?? 'API operation failed' },
393
+ })
394
+ continue
395
+ }
396
+ const after = await loadProductForScope(em, ctx, tenantId, product.id)
397
+ results.push({
398
+ recordId: product.id,
399
+ status: 'updated',
400
+ before,
401
+ after: after ? productSnapshot(after) : null,
402
+ })
403
+ } catch (error) {
404
+ failedRecordIds.push(product.id)
405
+ const message = error instanceof Error ? error.message : String(error)
406
+ const code = ((error as { code?: unknown })?.code as string | undefined) ?? 'command_failed'
407
+ results.push({
408
+ recordId: product.id,
409
+ status: 'failed',
410
+ before,
411
+ after: null,
412
+ error: { code, message },
413
+ })
414
+ }
415
+ }
416
+ const everyFailed = results.length > 0 && results.every((entry) => entry.status !== 'updated')
417
+ return {
418
+ commandName: 'catalog.products.update',
419
+ records: results,
420
+ failedRecordIds,
421
+ error: everyFailed ? { code: 'all_records_failed', message: 'No records were updated.' } : undefined,
422
+ }
423
+ },
424
+ }
425
+
426
+ /* -------------------------------------------------------------------------- */
427
+ /* catalog.apply_attribute_extraction (batch) */
428
+ /* -------------------------------------------------------------------------- */
429
+
430
+ const attributeExtractionRecordSchema = z
431
+ .object({
432
+ recordId: z.string().uuid(),
433
+ attributes: z.record(z.string(), z.unknown()).describe('Attribute key → value map produced by catalog.extract_attributes_from_description.'),
434
+ })
435
+ .strict()
436
+
437
+ const applyAttributeExtractionInput = z
438
+ .object({
439
+ records: z
440
+ .array(attributeExtractionRecordSchema)
441
+ .min(1)
442
+ .max(50)
443
+ .describe('One extraction payload per product. Max 50 rows per batch.'),
444
+ })
445
+ .strict()
446
+
447
+ type ApplyAttributeExtractionInput = z.infer<typeof applyAttributeExtractionInput>
448
+
449
+ async function resolveAttributeKeyIndex(
450
+ ctx: CatalogToolContext,
451
+ tenantId: string,
452
+ ): Promise<Set<string>> {
453
+ try {
454
+ const em = resolveEm(ctx)
455
+ const index = await loadCustomFieldDefinitionIndex({
456
+ em,
457
+ entityIds: E.catalog.catalog_product as string,
458
+ tenantId,
459
+ organizationIds: ctx.organizationId ? [ctx.organizationId] : null,
460
+ })
461
+ const keys = new Set<string>()
462
+ if (index && typeof (index as any).keys === 'function') {
463
+ for (const key of (index as any).keys() as Iterable<string>) {
464
+ keys.add(String(key))
465
+ }
466
+ }
467
+ return keys
468
+ } catch {
469
+ return new Set<string>()
470
+ }
471
+ }
472
+
473
+ const applyAttributeExtractionTool: CatalogAiToolDefinition = {
474
+ name: 'catalog.apply_attribute_extraction',
475
+ displayName: 'Apply attribute extraction',
476
+ description:
477
+ 'Persist the structured attribute output of catalog.extract_attributes_from_description. Re-validates every attribute key against the CURRENT catalog.product custom-field schema before the pending action is created — schema drift trips attribute_not_in_schema.',
478
+ inputSchema: applyAttributeExtractionInput as z.ZodType<unknown>,
479
+ requiredFeatures: ['catalog.products.manage'],
480
+ tags: ['write', 'catalog', 'merchandising', 'attributes'],
481
+ isMutation: true,
482
+ isBulk: true,
483
+ loadBeforeRecords: async (rawInput, ctx): Promise<CatalogToolLoadBeforeRecord[]> => {
484
+ const { tenantId } = assertTenantScope(ctx)
485
+ const input: ApplyAttributeExtractionInput = applyAttributeExtractionInput.parse(rawInput)
486
+ const em = resolveEm(ctx)
487
+ const rows: CatalogToolLoadBeforeRecord[] = []
488
+ for (const entry of input.records) {
489
+ const product = await loadProductForScope(em, ctx, tenantId, entry.recordId)
490
+ if (!product) continue
491
+ rows.push({
492
+ recordId: product.id,
493
+ entityType: 'catalog.product',
494
+ label: productLabel(product),
495
+ recordVersion: recordVersionFromUpdatedAt(product.updatedAt),
496
+ before: { attributes: {} },
497
+ })
498
+ }
499
+ return rows
500
+ },
501
+ handler: async (rawInput, ctx) => {
502
+ const { tenantId } = assertTenantScope(ctx)
503
+ const input: ApplyAttributeExtractionInput = applyAttributeExtractionInput.parse(rawInput)
504
+ const em = resolveEm(ctx)
505
+ const runner = createAiApiOperationRunner(ctx as unknown as AiToolExecutionContext)
506
+ const knownKeys = await resolveAttributeKeyIndex(ctx, tenantId)
507
+ const results: BulkRecordResult[] = []
508
+ const failedRecordIds: string[] = []
509
+ for (const entry of input.records) {
510
+ const product = await loadProductForScope(em, ctx, tenantId, entry.recordId)
511
+ if (!product) {
512
+ failedRecordIds.push(entry.recordId)
513
+ results.push({
514
+ recordId: entry.recordId,
515
+ status: 'skipped',
516
+ before: null,
517
+ after: null,
518
+ error: { code: 'record_not_found', message: 'Product is not accessible to the caller.' },
519
+ })
520
+ continue
521
+ }
522
+ const attributeKeys = Object.keys(entry.attributes ?? {})
523
+ const unknownKey = knownKeys.size > 0
524
+ ? attributeKeys.find((key) => !knownKeys.has(key))
525
+ : undefined
526
+ if (unknownKey) {
527
+ failedRecordIds.push(product.id)
528
+ results.push({
529
+ recordId: product.id,
530
+ status: 'failed',
531
+ before: { attributes: {} },
532
+ after: null,
533
+ error: {
534
+ code: 'attribute_not_in_schema',
535
+ message: `Attribute "${unknownKey}" is not part of the current catalog.product schema.`,
536
+ },
537
+ })
538
+ continue
539
+ }
540
+ const organizationId = product.organizationId
541
+ const customFields: Record<string, unknown> = {}
542
+ for (const [key, value] of Object.entries(entry.attributes ?? {})) {
543
+ customFields[key] = value
544
+ }
545
+ const body: Record<string, unknown> = {
546
+ id: product.id,
547
+ tenantId,
548
+ organizationId,
549
+ customFields,
550
+ }
551
+ try {
552
+ const response = await runner.run({
553
+ method: 'PUT',
554
+ path: '/catalog/products',
555
+ body,
556
+ })
557
+ if (!response.success) {
558
+ const code =
559
+ typeof (response.details as { code?: unknown } | undefined)?.code === 'string'
560
+ ? ((response.details as { code: string }).code)
561
+ : 'command_failed'
562
+ failedRecordIds.push(product.id)
563
+ results.push({
564
+ recordId: product.id,
565
+ status: 'failed',
566
+ before: { attributes: {} },
567
+ after: null,
568
+ error: { code, message: response.error ?? 'API operation failed' },
569
+ })
570
+ continue
571
+ }
572
+ results.push({
573
+ recordId: product.id,
574
+ status: 'updated',
575
+ before: { attributes: {} },
576
+ after: { attributes: entry.attributes },
577
+ })
578
+ } catch (error) {
579
+ failedRecordIds.push(product.id)
580
+ const message = error instanceof Error ? error.message : String(error)
581
+ const code = ((error as { code?: unknown })?.code as string | undefined) ?? 'command_failed'
582
+ results.push({
583
+ recordId: product.id,
584
+ status: 'failed',
585
+ before: { attributes: {} },
586
+ after: null,
587
+ error: { code, message },
588
+ })
589
+ }
590
+ }
591
+ const everyFailed = results.length > 0 && results.every((entry) => entry.status !== 'updated')
592
+ return {
593
+ commandName: 'catalog.products.update',
594
+ records: results,
595
+ failedRecordIds,
596
+ error: everyFailed ? { code: 'all_records_failed', message: 'No records were updated.' } : undefined,
597
+ }
598
+ },
599
+ }
600
+
601
+ /* -------------------------------------------------------------------------- */
602
+ /* catalog.update_product_media_descriptions (one-or-many media updates) */
603
+ /* -------------------------------------------------------------------------- */
604
+
605
+ const mediaUpdateRecordSchema = z
606
+ .object({
607
+ mediaId: z.string().uuid(),
608
+ altText: z.string().trim().max(500).optional(),
609
+ caption: z.string().trim().max(1000).optional(),
610
+ })
611
+ .strict()
612
+
613
+ const updateProductMediaDescriptionsInput = z
614
+ .object({
615
+ mediaUpdates: z
616
+ .array(mediaUpdateRecordSchema)
617
+ .min(1)
618
+ .max(100)
619
+ .describe('One media record descriptor per attachment id. Exactly one entry is allowed for single-record writes; many entries for bulk writes.'),
620
+ })
621
+ .strict()
622
+
623
+ type UpdateProductMediaDescriptionsInput = z.infer<typeof updateProductMediaDescriptionsInput>
624
+
625
+ async function loadProductMediaForScope(
626
+ em: EntityManager,
627
+ ctx: CatalogToolContext,
628
+ tenantId: string,
629
+ mediaId: string,
630
+ ): Promise<Attachment | null> {
631
+ const where: Record<string, unknown> = {
632
+ id: mediaId,
633
+ tenantId,
634
+ entityId: E.catalog.catalog_product,
635
+ }
636
+ if (ctx.organizationId) where.organizationId = ctx.organizationId
637
+ const row = await findOneWithDecryption<Attachment>(
638
+ em,
639
+ Attachment,
640
+ where as any,
641
+ undefined,
642
+ buildScope(ctx, tenantId),
643
+ )
644
+ if (!row || row.tenantId !== tenantId) return null
645
+ if (ctx.organizationId && row.organizationId !== ctx.organizationId) return null
646
+ return row
647
+ }
648
+
649
+ function mediaSnapshot(row: Attachment): Record<string, unknown> {
650
+ const metadata = row.storageMetadata ?? {}
651
+ return {
652
+ altText: typeof metadata?.altText === 'string' ? metadata.altText : null,
653
+ caption: typeof metadata?.caption === 'string' ? metadata.caption : null,
654
+ }
655
+ }
656
+
657
+ function mediaLabel(row: Attachment): string {
658
+ if (row.fileName && row.fileName.trim().length) return row.fileName
659
+ return row.id
660
+ }
661
+
662
+ const updateProductMediaDescriptionsTool: CatalogAiToolDefinition = {
663
+ name: 'catalog.update_product_media_descriptions',
664
+ displayName: 'Update product media descriptions',
665
+ description:
666
+ 'Update altText / caption on one or many product media attachments in a single approval. Emits one pending action with records[] carrying per-media diffs. Accepts one entry for single-record writes or many for bulk writes — always routes through loadBeforeRecords to guarantee schema-consistent preview.',
667
+ inputSchema: updateProductMediaDescriptionsInput as z.ZodType<unknown>,
668
+ requiredFeatures: ['catalog.products.manage'],
669
+ tags: ['write', 'catalog', 'merchandising', 'media'],
670
+ isMutation: true,
671
+ isBulk: true,
672
+ loadBeforeRecords: async (rawInput, ctx): Promise<CatalogToolLoadBeforeRecord[]> => {
673
+ const { tenantId } = assertTenantScope(ctx)
674
+ const input: UpdateProductMediaDescriptionsInput =
675
+ updateProductMediaDescriptionsInput.parse(rawInput)
676
+ const em = resolveEm(ctx)
677
+ const rows: CatalogToolLoadBeforeRecord[] = []
678
+ for (const entry of input.mediaUpdates) {
679
+ const media = await loadProductMediaForScope(em, ctx, tenantId, entry.mediaId)
680
+ if (!media) continue
681
+ rows.push({
682
+ recordId: media.id,
683
+ entityType: 'catalog.product_media',
684
+ label: mediaLabel(media),
685
+ recordVersion: recordVersionFromUpdatedAt(media.createdAt ?? null),
686
+ before: mediaSnapshot(media),
687
+ })
688
+ }
689
+ return rows
690
+ },
691
+ handler: async (rawInput, ctx) => {
692
+ const { tenantId } = assertTenantScope(ctx)
693
+ const input: UpdateProductMediaDescriptionsInput =
694
+ updateProductMediaDescriptionsInput.parse(rawInput)
695
+ const em = resolveEm(ctx)
696
+ const results: BulkRecordResult[] = []
697
+ const failedRecordIds: string[] = []
698
+ const touched: Array<{ row: Attachment; patch: { altText?: string; caption?: string }; before: Record<string, unknown> }> = []
699
+ for (const entry of input.mediaUpdates) {
700
+ const media = await loadProductMediaForScope(em, ctx, tenantId, entry.mediaId)
701
+ if (!media) {
702
+ failedRecordIds.push(entry.mediaId)
703
+ results.push({
704
+ recordId: entry.mediaId,
705
+ status: 'skipped',
706
+ before: null,
707
+ after: null,
708
+ error: { code: 'record_not_found', message: 'Media is not accessible to the caller.' },
709
+ })
710
+ continue
711
+ }
712
+ const before = mediaSnapshot(media)
713
+ touched.push({
714
+ row: media,
715
+ patch: {
716
+ ...(entry.altText !== undefined ? { altText: entry.altText } : {}),
717
+ ...(entry.caption !== undefined ? { caption: entry.caption } : {}),
718
+ },
719
+ before,
720
+ })
721
+ }
722
+ if (touched.length > 0) {
723
+ for (const { row, patch } of touched) {
724
+ const next = { ...(row.storageMetadata ?? {}) } as Record<string, unknown>
725
+ if (patch.altText !== undefined) next.altText = patch.altText
726
+ if (patch.caption !== undefined) next.caption = patch.caption
727
+ row.storageMetadata = next
728
+ }
729
+ await em.flush()
730
+ for (const { row, patch, before } of touched) {
731
+ results.push({
732
+ recordId: row.id,
733
+ status: 'updated',
734
+ before,
735
+ after: {
736
+ altText: patch.altText ?? (before as any).altText ?? null,
737
+ caption: patch.caption ?? (before as any).caption ?? null,
738
+ },
739
+ })
740
+ }
741
+ }
742
+ const everyFailed = results.length > 0 && results.every((entry) => entry.status !== 'updated')
743
+ return {
744
+ commandName: 'catalog.products.media.update_descriptions',
745
+ records: results,
746
+ failedRecordIds,
747
+ error: everyFailed ? { code: 'all_records_failed', message: 'No media records were updated.' } : undefined,
748
+ }
749
+ },
750
+ }
751
+
752
+ /* -------------------------------------------------------------------------- */
753
+
754
+ export const mutationAiTools: CatalogAiToolDefinition[] = [
755
+ updateProductTool,
756
+ bulkUpdateProductsTool,
757
+ applyAttributeExtractionTool,
758
+ updateProductMediaDescriptionsTool,
759
+ ]
760
+
761
+ export default mutationAiTools