@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +13 -1
- package/dist/helpers/integration/api.js +29 -16
- package/dist/helpers/integration/api.js.map +2 -2
- package/dist/helpers/integration/auth.js +11 -6
- package/dist/helpers/integration/auth.js.map +3 -3
- package/dist/modules/auth/commands/roles.js +9 -12
- package/dist/modules/auth/commands/roles.js.map +2 -2
- package/dist/modules/catalog/ai-agents-context.js +147 -0
- package/dist/modules/catalog/ai-agents-context.js.map +7 -0
- package/dist/modules/catalog/ai-agents.js +383 -0
- package/dist/modules/catalog/ai-agents.js.map +7 -0
- package/dist/modules/catalog/ai-tools/_shared.js +318 -0
- package/dist/modules/catalog/ai-tools/_shared.js.map +7 -0
- package/dist/modules/catalog/ai-tools/authoring-pack.js +391 -0
- package/dist/modules/catalog/ai-tools/authoring-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/categories-pack.js +167 -0
- package/dist/modules/catalog/ai-tools/categories-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/configuration-pack.js +120 -0
- package/dist/modules/catalog/ai-tools/configuration-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/media-tags-pack.js +107 -0
- package/dist/modules/catalog/ai-tools/media-tags-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/merchandising-pack.js +429 -0
- package/dist/modules/catalog/ai-tools/merchandising-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/mutation-pack.js +576 -0
- package/dist/modules/catalog/ai-tools/mutation-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/prices-offers-pack.js +208 -0
- package/dist/modules/catalog/ai-tools/prices-offers-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/products-pack.js +298 -0
- package/dist/modules/catalog/ai-tools/products-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/stats-pack.js +57 -0
- package/dist/modules/catalog/ai-tools/stats-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/types.js +10 -0
- package/dist/modules/catalog/ai-tools/types.js.map +7 -0
- package/dist/modules/catalog/ai-tools/variants-pack.js +75 -0
- package/dist/modules/catalog/ai-tools/variants-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools.js +28 -0
- package/dist/modules/catalog/ai-tools.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js +466 -0
- package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/products/page.js +7 -1
- package/dist/modules/catalog/backend/catalog/products/page.js.map +2 -2
- package/dist/modules/catalog/components/CatalogStatsCard.js +91 -0
- package/dist/modules/catalog/components/CatalogStatsCard.js.map +7 -0
- package/dist/modules/catalog/components/products/ProductsDataTable.js +23 -3
- package/dist/modules/catalog/components/products/ProductsDataTable.js.map +2 -2
- package/dist/modules/catalog/events.js +7 -4
- package/dist/modules/catalog/events.js.map +2 -2
- package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.js +59 -0
- package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.js.map +7 -0
- package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.js +17 -0
- package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.js.map +7 -0
- package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js +1 -1
- package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js.map +2 -2
- package/dist/modules/catalog/widgets/injection-table.js +13 -1
- package/dist/modules/catalog/widgets/injection-table.js.map +2 -2
- package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js +94 -0
- package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js.map +7 -0
- package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.js +17 -0
- package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.js.map +7 -0
- package/dist/modules/customer_accounts/widgets/injection-table.js +9 -0
- package/dist/modules/customer_accounts/widgets/injection-table.js.map +2 -2
- package/dist/modules/customers/ai-agents-context.js +96 -0
- package/dist/modules/customers/ai-agents-context.js.map +7 -0
- package/dist/modules/customers/ai-agents.js +244 -0
- package/dist/modules/customers/ai-agents.js.map +7 -0
- package/dist/modules/customers/ai-tools/activities-tasks-pack.js +1015 -0
- package/dist/modules/customers/ai-tools/activities-tasks-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/addresses-tags-pack.js +134 -0
- package/dist/modules/customers/ai-tools/addresses-tags-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/companies-pack.js +249 -0
- package/dist/modules/customers/ai-tools/companies-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/deals-pack.js +348 -0
- package/dist/modules/customers/ai-tools/deals-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/people-pack.js +261 -0
- package/dist/modules/customers/ai-tools/people-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/settings-pack.js +102 -0
- package/dist/modules/customers/ai-tools/settings-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/types.js +10 -0
- package/dist/modules/customers/ai-tools/types.js.map +7 -0
- package/dist/modules/customers/ai-tools.js +20 -0
- package/dist/modules/customers/ai-tools.js.map +7 -0
- package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js +469 -0
- package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js.map +7 -0
- package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.js +17 -0
- package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.js.map +7 -0
- package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.js +117 -0
- package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.js.map +7 -0
- package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.js +17 -0
- package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.js.map +7 -0
- package/dist/modules/customers/widgets/injection-table.js +26 -0
- package/dist/modules/customers/widgets/injection-table.js.map +7 -0
- package/dist/modules/inbox_ops/ai-tools.js +4 -0
- package/dist/modules/inbox_ops/ai-tools.js.map +2 -2
- package/dist/modules/inbox_ops/lib/llmProvider.js +52 -7
- package/dist/modules/inbox_ops/lib/llmProvider.js.map +2 -2
- package/dist/modules/notifications/setup.js +13 -0
- package/dist/modules/notifications/setup.js.map +7 -0
- package/jest.config.cjs +1 -0
- package/jest.setup.ts +18 -0
- package/package.json +5 -3
- package/src/helpers/integration/api.ts +38 -16
- package/src/helpers/integration/auth.ts +13 -6
- package/src/modules/auth/commands/roles.ts +10 -12
- package/src/modules/catalog/AGENTS.md +11 -0
- package/src/modules/catalog/ai-agents-context.ts +239 -0
- package/src/modules/catalog/ai-agents.ts +525 -0
- package/src/modules/catalog/ai-tools/_shared.ts +487 -0
- package/src/modules/catalog/ai-tools/authoring-pack.ts +600 -0
- package/src/modules/catalog/ai-tools/categories-pack.ts +192 -0
- package/src/modules/catalog/ai-tools/configuration-pack.ts +218 -0
- package/src/modules/catalog/ai-tools/media-tags-pack.ts +127 -0
- package/src/modules/catalog/ai-tools/merchandising-pack.ts +608 -0
- package/src/modules/catalog/ai-tools/mutation-pack.ts +761 -0
- package/src/modules/catalog/ai-tools/prices-offers-pack.ts +376 -0
- package/src/modules/catalog/ai-tools/products-pack.ts +387 -0
- package/src/modules/catalog/ai-tools/stats-pack.ts +84 -0
- package/src/modules/catalog/ai-tools/types.ts +81 -0
- package/src/modules/catalog/ai-tools/variants-pack.ts +147 -0
- package/src/modules/catalog/ai-tools.ts +78 -0
- package/src/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.tsx +597 -0
- package/src/modules/catalog/backend/catalog/products/page.tsx +23 -2
- package/src/modules/catalog/components/CatalogStatsCard.tsx +118 -0
- package/src/modules/catalog/components/products/ProductsDataTable.tsx +54 -6
- package/src/modules/catalog/events.ts +7 -4
- package/src/modules/catalog/i18n/de.json +17 -0
- package/src/modules/catalog/i18n/en.json +17 -0
- package/src/modules/catalog/i18n/es.json +17 -0
- package/src/modules/catalog/i18n/pl.json +17 -0
- package/src/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.tsx +109 -0
- package/src/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.ts +29 -0
- package/src/modules/catalog/widgets/injection/product-seo/widget.client.tsx +1 -1
- package/src/modules/catalog/widgets/injection-table.ts +12 -0
- package/src/modules/customer_accounts/i18n/de.json +5 -0
- package/src/modules/customer_accounts/i18n/en.json +5 -0
- package/src/modules/customer_accounts/i18n/es.json +5 -0
- package/src/modules/customer_accounts/i18n/pl.json +5 -0
- package/src/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.tsx +136 -0
- package/src/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.ts +43 -0
- package/src/modules/customer_accounts/widgets/injection-table.ts +9 -0
- package/src/modules/customers/AGENTS.md +13 -0
- package/src/modules/customers/ai-agents-context.ts +150 -0
- package/src/modules/customers/ai-agents.ts +355 -0
- package/src/modules/customers/ai-tools/activities-tasks-pack.ts +1248 -0
- package/src/modules/customers/ai-tools/addresses-tags-pack.ts +145 -0
- package/src/modules/customers/ai-tools/companies-pack.ts +362 -0
- package/src/modules/customers/ai-tools/deals-pack.ts +505 -0
- package/src/modules/customers/ai-tools/people-pack.ts +369 -0
- package/src/modules/customers/ai-tools/settings-pack.ts +121 -0
- package/src/modules/customers/ai-tools/types.ts +76 -0
- package/src/modules/customers/ai-tools.ts +34 -0
- package/src/modules/customers/i18n/de.json +25 -0
- package/src/modules/customers/i18n/en.json +25 -0
- package/src/modules/customers/i18n/es.json +25 -0
- package/src/modules/customers/i18n/pl.json +25 -0
- package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.tsx +580 -0
- package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.ts +36 -0
- package/src/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.tsx +191 -0
- package/src/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.ts +37 -0
- package/src/modules/customers/widgets/injection-table.ts +41 -0
- package/src/modules/inbox_ops/ai-tools.ts +4 -0
- package/src/modules/inbox_ops/lib/llmProvider.ts +83 -7
- 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
|