@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,387 @@
1
+ /**
2
+ * `catalog.list_products` + `catalog.get_product` (Phase 1 WS-C, Step 3.10).
3
+ *
4
+ * Read-only tools scoped to `ctx.tenantId` + `ctx.organizationId`. Mutation
5
+ * tools are deferred to Step 5.14 under the pending-action contract.
6
+ *
7
+ * Phase 3b of `.ai/specs/2026-04-27-ai-tools-api-backed-dry-refactor.md`:
8
+ * `catalog.list_products` is now an API-backed wrapper over
9
+ * `GET /api/catalog/products`. Tool name, schema, requiredFeatures, and
10
+ * output shape are unchanged.
11
+ */
12
+ import type { EntityManager } from '@mikro-orm/postgresql'
13
+ import { z } from 'zod'
14
+ import { defineApiBackedAiTool } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/api-backed-tool'
15
+ import type {
16
+ AiApiOperationRequest,
17
+ AiToolExecutionContext,
18
+ } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-api-operation-runner'
19
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
20
+ import { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'
21
+ import { E } from '#generated/entities.ids.generated'
22
+ import { Attachment } from '@open-mercato/core/modules/attachments/data/entities'
23
+ import {
24
+ CatalogProduct,
25
+ CatalogProductCategoryAssignment,
26
+ CatalogProductTagAssignment,
27
+ CatalogProductTag,
28
+ CatalogProductVariant,
29
+ CatalogProductPrice,
30
+ CatalogProductUnitConversion,
31
+ } from '../data/entities'
32
+ import { assertTenantScope, type CatalogAiToolDefinition, type CatalogToolContext } from './types'
33
+
34
+ function resolveEm(ctx: CatalogToolContext | AiToolExecutionContext): EntityManager {
35
+ return ctx.container.resolve<EntityManager>('em')
36
+ }
37
+
38
+ function buildScope(ctx: CatalogToolContext | AiToolExecutionContext, tenantId: string) {
39
+ return { tenantId, organizationId: ctx.organizationId }
40
+ }
41
+
42
+ const listProductsInput = z
43
+ .object({
44
+ q: z.string().trim().min(1).optional().describe('Optional search text matched against title / subtitle / sku / handle.'),
45
+ limit: z.number().int().min(1).max(100).optional().describe('Maximum rows to return (default 50, max 100).'),
46
+ offset: z.number().int().min(0).optional().describe('Number of rows to skip (default 0).'),
47
+ categoryId: z.string().uuid().optional().describe('Restrict to products assigned to this catalog category.'),
48
+ tagIds: z.array(z.string().uuid()).optional().describe('Restrict to products carrying at least one of these tag ids.'),
49
+ active: z.boolean().optional().describe('When true, only active (not archived) products are returned.'),
50
+ })
51
+ .passthrough()
52
+
53
+ type ListProductsInput = z.infer<typeof listProductsInput>
54
+
55
+ type ListProductsApiItem = {
56
+ id?: string
57
+ title?: string | null
58
+ subtitle?: string | null
59
+ sku?: string | null
60
+ handle?: string | null
61
+ product_type?: string | null
62
+ productType?: string | null
63
+ status_entry_id?: string | null
64
+ statusEntryId?: string | null
65
+ primary_currency_code?: string | null
66
+ primaryCurrencyCode?: string | null
67
+ default_media_id?: string | null
68
+ defaultMediaId?: string | null
69
+ default_media_url?: string | null
70
+ defaultMediaUrl?: string | null
71
+ is_active?: boolean | null
72
+ isActive?: boolean | null
73
+ is_configurable?: boolean | null
74
+ isConfigurable?: boolean | null
75
+ organization_id?: string | null
76
+ organizationId?: string | null
77
+ tenant_id?: string | null
78
+ tenantId?: string | null
79
+ created_at?: string | null
80
+ createdAt?: string | null
81
+ updated_at?: string | null
82
+ updatedAt?: string | null
83
+ }
84
+
85
+ type ListProductsApiResponse = {
86
+ items?: ListProductsApiItem[]
87
+ total?: number
88
+ }
89
+
90
+ type ListProductsOutput = {
91
+ items: Array<Record<string, unknown>>
92
+ total: number
93
+ limit: number
94
+ offset: number
95
+ }
96
+
97
+ const listProductsTool = defineApiBackedAiTool<
98
+ ListProductsInput,
99
+ ListProductsApiResponse,
100
+ ListProductsOutput
101
+ >({
102
+ name: 'catalog.list_products',
103
+ displayName: 'List products',
104
+ description:
105
+ 'Search / list catalog products for the caller tenant + organization. Returns { items, total, limit, offset }.',
106
+ inputSchema: listProductsInput,
107
+ requiredFeatures: ['catalog.products.view'],
108
+ toOperation: (input, ctx) => {
109
+ assertTenantScope(ctx as unknown as CatalogToolContext)
110
+ const limit = input.limit ?? 50
111
+ const offset = input.offset ?? 0
112
+ const page = Math.floor(offset / limit) + 1
113
+
114
+ const query: Record<string, string | number | boolean | null | undefined> = {
115
+ page,
116
+ pageSize: limit,
117
+ }
118
+ if (input.q?.trim()) query.search = input.q.trim()
119
+ if (input.categoryId) query.categoryIds = input.categoryId
120
+ if (input.tagIds && input.tagIds.length > 0) query.tagIds = input.tagIds.join(',')
121
+ if (input.active === true) query.isActive = 'true'
122
+
123
+ const operation: AiApiOperationRequest = {
124
+ method: 'GET',
125
+ path: '/catalog/products',
126
+ query,
127
+ }
128
+ return operation
129
+ },
130
+ mapResponse: (response, input) => {
131
+ const limit = input.limit ?? 50
132
+ const offset = input.offset ?? 0
133
+ const data = (response.data ?? {}) as ListProductsApiResponse
134
+ const rawItems: ListProductsApiItem[] = Array.isArray(data.items) ? data.items : []
135
+ return {
136
+ items: rawItems.map((row) => {
137
+ const createdAtRaw = row.created_at ?? row.createdAt ?? null
138
+ const createdAt = createdAtRaw ? new Date(String(createdAtRaw)).toISOString() : null
139
+ const updatedAtRaw = row.updated_at ?? row.updatedAt ?? null
140
+ const updatedAt = updatedAtRaw ? new Date(String(updatedAtRaw)).toISOString() : null
141
+ return {
142
+ id: row.id,
143
+ title: row.title ?? null,
144
+ subtitle: row.subtitle ?? null,
145
+ sku: row.sku ?? null,
146
+ handle: row.handle ?? null,
147
+ productType: row.product_type ?? row.productType ?? null,
148
+ statusEntryId: row.status_entry_id ?? row.statusEntryId ?? null,
149
+ primaryCurrencyCode: row.primary_currency_code ?? row.primaryCurrencyCode ?? null,
150
+ defaultMediaId: row.default_media_id ?? row.defaultMediaId ?? null,
151
+ defaultMediaUrl: row.default_media_url ?? row.defaultMediaUrl ?? null,
152
+ imageUrl: row.default_media_url ?? row.defaultMediaUrl ?? null,
153
+ isActive: !!(row.is_active ?? row.isActive),
154
+ isConfigurable: !!(row.is_configurable ?? row.isConfigurable),
155
+ organizationId: row.organization_id ?? row.organizationId ?? null,
156
+ tenantId: row.tenant_id ?? row.tenantId ?? null,
157
+ createdAt,
158
+ updatedAt,
159
+ }
160
+ }),
161
+ total: typeof data.total === 'number' ? data.total : 0,
162
+ limit,
163
+ offset,
164
+ }
165
+ },
166
+ }) as unknown as CatalogAiToolDefinition
167
+
168
+ const getProductInput = z.object({
169
+ productId: z.string().uuid().describe('Catalog product id (UUID).'),
170
+ includeRelated: z
171
+ .boolean()
172
+ .optional()
173
+ .describe(
174
+ 'When true, include categories, tags, variants, prices (base + offers), media (metadata only), unit conversions, and custom fields (each related list capped at 100).',
175
+ ),
176
+ })
177
+
178
+ const getProductTool: CatalogAiToolDefinition = {
179
+ name: 'catalog.get_product',
180
+ displayName: 'Get product',
181
+ description:
182
+ 'Fetch a catalog product by id with core fields and (optionally) categories, tags, variants, prices, media metadata, unit conversions, and custom fields. Returns { found: false } when the record is outside tenant/org scope or missing.',
183
+ inputSchema: getProductInput,
184
+ requiredFeatures: ['catalog.products.view'],
185
+ tags: ['read', 'catalog'],
186
+ handler: async (rawInput, ctx) => {
187
+ const { tenantId } = assertTenantScope(ctx)
188
+ const input = getProductInput.parse(rawInput)
189
+ const em = resolveEm(ctx)
190
+ const where: Record<string, unknown> = {
191
+ id: input.productId,
192
+ tenantId,
193
+ deletedAt: null,
194
+ }
195
+ if (ctx.organizationId) where.organizationId = ctx.organizationId
196
+ const product = await findOneWithDecryption<CatalogProduct>(
197
+ em,
198
+ CatalogProduct,
199
+ where as any,
200
+ undefined,
201
+ buildScope(ctx, tenantId),
202
+ )
203
+ if (!product || product.tenantId !== tenantId) {
204
+ return { found: false as const, productId: input.productId }
205
+ }
206
+
207
+ let related: Record<string, unknown> | null = null
208
+ let customFields: Record<string, unknown> = {}
209
+ if (input.includeRelated) {
210
+ const scope = buildScope(ctx, tenantId)
211
+ const [
212
+ categoryAssignments,
213
+ tagAssignments,
214
+ variants,
215
+ prices,
216
+ mediaAttachments,
217
+ unitConversions,
218
+ customFieldValues,
219
+ ] = await Promise.all([
220
+ findWithDecryption<CatalogProductCategoryAssignment>(
221
+ em,
222
+ CatalogProductCategoryAssignment,
223
+ { tenantId, product: product.id } as any,
224
+ { limit: 100, populate: ['category'] as any } as any,
225
+ scope,
226
+ ),
227
+ findWithDecryption<CatalogProductTagAssignment>(
228
+ em,
229
+ CatalogProductTagAssignment,
230
+ { tenantId, product: product.id } as any,
231
+ { limit: 100, populate: ['tag'] as any } as any,
232
+ scope,
233
+ ),
234
+ findWithDecryption<CatalogProductVariant>(
235
+ em,
236
+ CatalogProductVariant,
237
+ { tenantId, product: product.id, deletedAt: null } as any,
238
+ { limit: 100, orderBy: { createdAt: 'asc' } as any } as any,
239
+ scope,
240
+ ),
241
+ findWithDecryption<CatalogProductPrice>(
242
+ em,
243
+ CatalogProductPrice,
244
+ { tenantId, product: product.id } as any,
245
+ { limit: 100, orderBy: { createdAt: 'asc' } as any } as any,
246
+ scope,
247
+ ),
248
+ findWithDecryption<Attachment>(
249
+ em,
250
+ Attachment,
251
+ { tenantId, entityId: E.catalog.catalog_product, recordId: product.id } as any,
252
+ { limit: 100, orderBy: { createdAt: 'asc' } as any } as any,
253
+ scope,
254
+ ),
255
+ findWithDecryption<CatalogProductUnitConversion>(
256
+ em,
257
+ CatalogProductUnitConversion,
258
+ { tenantId, product: product.id, deletedAt: null } as any,
259
+ { limit: 100, orderBy: { sortOrder: 'asc', createdAt: 'asc' } as any } as any,
260
+ scope,
261
+ ),
262
+ loadCustomFieldValues({
263
+ em,
264
+ entityId: E.catalog.catalog_product,
265
+ recordIds: [product.id],
266
+ tenantIdByRecord: { [product.id]: product.tenantId ?? null },
267
+ organizationIdByRecord: { [product.id]: product.organizationId ?? null },
268
+ tenantFallbacks: [product.tenantId ?? tenantId].filter((value): value is string => !!value),
269
+ }),
270
+ ])
271
+ customFields = customFieldValues[product.id] ?? {}
272
+ related = {
273
+ categories: categoryAssignments
274
+ .map((assignment) => {
275
+ const category = (assignment as any).category
276
+ if (!category || typeof category === 'string') {
277
+ const fallbackId = typeof category === 'string' ? category : null
278
+ return fallbackId ? { id: fallbackId, name: null, slug: null } : null
279
+ }
280
+ return {
281
+ id: category.id,
282
+ name: category.name ?? null,
283
+ slug: category.slug ?? null,
284
+ }
285
+ })
286
+ .filter((value): value is { id: string; name: string | null; slug: string | null } => value !== null),
287
+ tags: tagAssignments
288
+ .map((assignment) => {
289
+ const tag = (assignment as any).tag as CatalogProductTag | string | null
290
+ if (!tag || typeof tag === 'string') return null
291
+ return { id: tag.id, label: tag.label, slug: tag.slug }
292
+ })
293
+ .filter((value): value is { id: string; label: string; slug: string } => value !== null),
294
+ variants: variants.map((variant) => ({
295
+ id: variant.id,
296
+ name: variant.name ?? null,
297
+ sku: variant.sku ?? null,
298
+ barcode: variant.barcode ?? null,
299
+ optionValues: variant.optionValues ?? null,
300
+ defaultMediaId: variant.defaultMediaId ?? null,
301
+ defaultMediaUrl: variant.defaultMediaUrl ?? null,
302
+ isDefault: !!variant.isDefault,
303
+ isActive: !!variant.isActive,
304
+ })),
305
+ prices: prices.map((price) => ({
306
+ id: price.id,
307
+ priceKindId: (price as any).priceKind && typeof (price as any).priceKind === 'object'
308
+ ? (price as any).priceKind.id
309
+ : (price as any).priceKind ?? null,
310
+ currencyCode: price.currencyCode,
311
+ kind: price.kind,
312
+ minQuantity: price.minQuantity,
313
+ maxQuantity: price.maxQuantity ?? null,
314
+ unitPriceNet: price.unitPriceNet ?? null,
315
+ unitPriceGross: price.unitPriceGross ?? null,
316
+ channelId: price.channelId ?? null,
317
+ offerId: (price as any).offer && typeof (price as any).offer === 'object'
318
+ ? (price as any).offer.id
319
+ : (price as any).offer ?? null,
320
+ variantId: (price as any).variant && typeof (price as any).variant === 'object'
321
+ ? (price as any).variant.id
322
+ : (price as any).variant ?? null,
323
+ startsAt: price.startsAt ? new Date(price.startsAt).toISOString() : null,
324
+ endsAt: price.endsAt ? new Date(price.endsAt).toISOString() : null,
325
+ })),
326
+ media: mediaAttachments.map((attachment) => ({
327
+ id: attachment.id,
328
+ fileName: attachment.fileName,
329
+ mimeType: attachment.mimeType,
330
+ fileSize: attachment.fileSize,
331
+ url: attachment.url,
332
+ })),
333
+ unitConversions: unitConversions.map((row) => ({
334
+ id: row.id,
335
+ unitCode: row.unitCode,
336
+ toBaseFactor: row.toBaseFactor,
337
+ sortOrder: row.sortOrder,
338
+ isActive: !!row.isActive,
339
+ })),
340
+ customFields,
341
+ }
342
+ }
343
+
344
+ return {
345
+ found: true as const,
346
+ product: {
347
+ id: product.id,
348
+ title: product.title,
349
+ subtitle: product.subtitle ?? null,
350
+ description: product.description ?? null,
351
+ sku: product.sku ?? null,
352
+ handle: product.handle ?? null,
353
+ productType: product.productType,
354
+ statusEntryId: product.statusEntryId ?? null,
355
+ primaryCurrencyCode: product.primaryCurrencyCode ?? null,
356
+ taxRate: product.taxRate ?? null,
357
+ taxRateId: product.taxRateId ?? null,
358
+ defaultUnit: product.defaultUnit ?? null,
359
+ defaultSalesUnit: product.defaultSalesUnit ?? null,
360
+ defaultSalesUnitQuantity: product.defaultSalesUnitQuantity ?? null,
361
+ unitPriceEnabled: !!product.unitPriceEnabled,
362
+ unitPriceReferenceUnit: product.unitPriceReferenceUnit ?? null,
363
+ unitPriceBaseQuantity: product.unitPriceBaseQuantity ?? null,
364
+ defaultMediaId: product.defaultMediaId ?? null,
365
+ defaultMediaUrl: product.defaultMediaUrl ?? null,
366
+ imageUrl: product.defaultMediaUrl ?? null,
367
+ weightValue: product.weightValue ?? null,
368
+ weightUnit: product.weightUnit ?? null,
369
+ dimensions: product.dimensions ?? null,
370
+ metadata: product.metadata ?? null,
371
+ customFieldsetCode: product.customFieldsetCode ?? null,
372
+ isConfigurable: !!product.isConfigurable,
373
+ isActive: !!product.isActive,
374
+ organizationId: product.organizationId ?? null,
375
+ tenantId: product.tenantId ?? null,
376
+ createdAt: product.createdAt ? new Date(product.createdAt).toISOString() : null,
377
+ updatedAt: product.updatedAt ? new Date(product.updatedAt).toISOString() : null,
378
+ },
379
+ customFields,
380
+ related,
381
+ }
382
+ },
383
+ }
384
+
385
+ export const productsAiTools: CatalogAiToolDefinition[] = [listProductsTool, getProductTool]
386
+
387
+ export default productsAiTools
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `catalog.show_stats` — example dynamic UI-part tool.
3
+ *
4
+ * Demonstrates the generic UI-part contract: any tool can return a JSON
5
+ * envelope `{ uiPart: { componentId, payload } }` (or a `uiParts: [...]`
6
+ * array) and the chat client surfaces it inline. The catalog stats card
7
+ * is the canonical dynamic example; module authors copy this file +
8
+ * `components/CatalogStatsCard.tsx` to ship their own cards.
9
+ *
10
+ * Read-only — no `prepareMutation` gate, no DB writes.
11
+ */
12
+
13
+ import type { EntityManager } from '@mikro-orm/postgresql'
14
+ import { z } from 'zod'
15
+ import { CatalogProduct, CatalogProductCategory, CatalogProductTag } from '../data/entities'
16
+ import { assertTenantScope, type CatalogAiToolDefinition, type CatalogToolContext } from './types'
17
+
18
+ function resolveEm(ctx: CatalogToolContext): EntityManager {
19
+ return ctx.container.resolve<EntityManager>('em')
20
+ }
21
+
22
+ const showStatsInput = z
23
+ .object({
24
+ note: z
25
+ .string()
26
+ .max(160)
27
+ .optional()
28
+ .describe(
29
+ 'Optional one-line note to render below the stats grid (e.g. "as of today" or a quick observation).',
30
+ ),
31
+ })
32
+ .passthrough()
33
+
34
+ const showStatsTool: CatalogAiToolDefinition = {
35
+ name: 'catalog.show_stats',
36
+ displayName: 'Show catalog stats',
37
+ description:
38
+ 'Displays a compact "Catalog overview" card in the chat with live counts: total products, active products, categories, and tags for the current tenant. Use this when the operator asks for a high-level snapshot of the catalog (e.g. "give me catalog stats", "how many products do we have", "show overview"). Returns a `uiPart` envelope so the registered `catalog.stats-card` component renders inline — no fenced code block needed.',
39
+ inputSchema: showStatsInput,
40
+ requiredFeatures: ['catalog.products.view'],
41
+ tags: ['read', 'catalog', 'stats', 'ui'],
42
+ isMutation: false,
43
+ handler: async (rawInput, ctx) => {
44
+ const { tenantId } = assertTenantScope(ctx)
45
+ const input = showStatsInput.parse(rawInput)
46
+ const em = resolveEm(ctx)
47
+
48
+ // CatalogProductTag has no soft-delete column, so the `deletedAt: null`
49
+ // filter (used for products + categories which DO have it) would throw
50
+ // `Trying to query by not existing property CatalogProductTag.deletedAt`.
51
+ // Build per-entity scopes that only include the fields that actually exist.
52
+ const tenantScope: Record<string, unknown> = { tenantId }
53
+ if (ctx.organizationId) tenantScope.organizationId = ctx.organizationId
54
+ const softDeleteScope: Record<string, unknown> = { ...tenantScope, deletedAt: null }
55
+
56
+ const [products, activeProducts, categories, tags] = await Promise.all([
57
+ em.count(CatalogProduct, softDeleteScope as never),
58
+ em.count(CatalogProduct, { ...softDeleteScope, isActive: true } as never),
59
+ em.count(CatalogProductCategory, softDeleteScope as never),
60
+ em.count(CatalogProductTag, tenantScope as never),
61
+ ])
62
+
63
+ return {
64
+ uiPart: {
65
+ componentId: 'catalog.stats-card',
66
+ payload: {
67
+ products,
68
+ activeProducts,
69
+ categories,
70
+ tags,
71
+ generatedAt: new Date().toISOString(),
72
+ note: input.note,
73
+ },
74
+ },
75
+ // Plain-text mirror so the model can summarize what it just rendered
76
+ // without parsing the UI part envelope itself.
77
+ summary: `Catalog snapshot: ${products} products (${activeProducts} active), ${categories} categories, ${tags} tags.`,
78
+ }
79
+ },
80
+ }
81
+
82
+ export const statsAiTools: CatalogAiToolDefinition[] = [showStatsTool]
83
+
84
+ export default statsAiTools
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Local AI tool shape for the catalog module (Phase 1 WS-C, Step 3.10).
3
+ *
4
+ * Mirrors the pattern established by the customers pack (Step 3.9): the
5
+ * catalog module declares its read-only tools as plain objects whose shape
6
+ * is a strict subset of `AiToolDefinition` from `@open-mercato/ai-assistant`.
7
+ * Keeping the shape local avoids pulling the ai-assistant package into the
8
+ * core module graph for jest and sidesteps a cross-package `moduleNameMapper`.
9
+ * The generator walks every module root for a default / `aiTools` export with
10
+ * this shape.
11
+ */
12
+ import type { z } from 'zod'
13
+ import type { AwilixContainer } from 'awilix'
14
+
15
+ export interface CatalogToolContext {
16
+ tenantId: string | null
17
+ organizationId: string | null
18
+ userId: string | null
19
+ container: AwilixContainer
20
+ userFeatures: string[]
21
+ isSuperAdmin: boolean
22
+ apiKeySecret?: string
23
+ sessionId?: string
24
+ }
25
+
26
+ /**
27
+ * Shape returned by `loadBeforeRecord` on a single-record mutation tool.
28
+ * Mirrors `AiToolLoadBeforeSingleRecord` from `@open-mercato/ai-assistant/lib/types`.
29
+ */
30
+ export interface CatalogToolLoadBeforeSingleRecord {
31
+ recordId: string
32
+ entityType: string
33
+ recordVersion: string | null
34
+ before: Record<string, unknown>
35
+ }
36
+
37
+ /**
38
+ * Shape returned by `loadBeforeRecords` on a bulk mutation tool. Mirrors
39
+ * `AiToolLoadBeforeRecord` from `@open-mercato/ai-assistant/lib/types` — the
40
+ * Step 5.6 `prepareMutation` runtime wraps this into the `records[]` array on
41
+ * the emitted `mutation-preview-card`.
42
+ */
43
+ export interface CatalogToolLoadBeforeRecord {
44
+ recordId: string
45
+ entityType: string
46
+ label: string
47
+ recordVersion: string | null
48
+ before: Record<string, unknown>
49
+ }
50
+
51
+ export interface CatalogAiToolDefinition<TInput = unknown, TOutput = unknown> {
52
+ name: string
53
+ displayName?: string
54
+ description: string
55
+ inputSchema: z.ZodType<TInput>
56
+ requiredFeatures?: string[]
57
+ tags?: string[]
58
+ isMutation?: boolean
59
+ isBulk?: boolean
60
+ maxCallsPerTurn?: number
61
+ supportsAttachments?: boolean
62
+ handler: (input: TInput, context: CatalogToolContext) => Promise<TOutput>
63
+ loadBeforeRecord?: (
64
+ input: TInput,
65
+ context: CatalogToolContext,
66
+ ) => Promise<CatalogToolLoadBeforeSingleRecord | null>
67
+ loadBeforeRecords?: (
68
+ input: TInput,
69
+ context: CatalogToolContext,
70
+ ) => Promise<CatalogToolLoadBeforeRecord[]>
71
+ }
72
+
73
+ export function assertTenantScope(ctx: CatalogToolContext): {
74
+ tenantId: string
75
+ organizationId: string | null
76
+ } {
77
+ if (!ctx.tenantId) {
78
+ throw new Error('Tenant context is required for catalog.* tools')
79
+ }
80
+ return { tenantId: ctx.tenantId, organizationId: ctx.organizationId }
81
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `catalog.list_variants` (Phase 1 WS-C, Step 3.10).
3
+ *
4
+ * Enumerate variants for a single product with option values + media refs.
5
+ *
6
+ * Phase 3b of `.ai/specs/2026-04-27-ai-tools-api-backed-dry-refactor.md`:
7
+ * `catalog.list_variants` is now an API-backed wrapper over
8
+ * `GET /api/catalog/variants`. Tool name, schema, requiredFeatures, and
9
+ * output shape are unchanged.
10
+ */
11
+ import { z } from 'zod'
12
+ import { defineApiBackedAiTool } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/api-backed-tool'
13
+ import type {
14
+ AiApiOperationRequest,
15
+ AiToolExecutionContext,
16
+ } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-api-operation-runner'
17
+ import { assertTenantScope, type CatalogAiToolDefinition, type CatalogToolContext } from './types'
18
+
19
+ const listVariantsInput = z
20
+ .object({
21
+ productId: z.string().uuid().describe('Parent product id (UUID).'),
22
+ limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),
23
+ offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),
24
+ })
25
+ .passthrough()
26
+
27
+ type ListVariantsInput = z.infer<typeof listVariantsInput>
28
+
29
+ type ListVariantsApiItem = {
30
+ id?: string
31
+ product_id?: string | null
32
+ productId?: string | null
33
+ name?: string | null
34
+ sku?: string | null
35
+ barcode?: string | null
36
+ status_entry_id?: string | null
37
+ statusEntryId?: string | null
38
+ option_values?: unknown
39
+ optionValues?: unknown
40
+ default_media_id?: string | null
41
+ defaultMediaId?: string | null
42
+ default_media_url?: string | null
43
+ defaultMediaUrl?: string | null
44
+ weight_value?: string | number | null
45
+ weightValue?: string | number | null
46
+ weight_unit?: string | null
47
+ weightUnit?: string | null
48
+ dimensions?: unknown
49
+ tax_rate?: string | number | null
50
+ taxRate?: string | number | null
51
+ tax_rate_id?: string | null
52
+ taxRateId?: string | null
53
+ is_default?: boolean | null
54
+ isDefault?: boolean | null
55
+ is_active?: boolean | null
56
+ isActive?: boolean | null
57
+ organization_id?: string | null
58
+ organizationId?: string | null
59
+ tenant_id?: string | null
60
+ tenantId?: string | null
61
+ created_at?: string | null
62
+ createdAt?: string | null
63
+ }
64
+
65
+ type ListVariantsApiResponse = {
66
+ items?: ListVariantsApiItem[]
67
+ total?: number
68
+ }
69
+
70
+ type ListVariantsOutput = {
71
+ items: Array<Record<string, unknown>>
72
+ total: number
73
+ limit: number
74
+ offset: number
75
+ }
76
+
77
+ const listVariantsTool = defineApiBackedAiTool<
78
+ ListVariantsInput,
79
+ ListVariantsApiResponse,
80
+ ListVariantsOutput
81
+ >({
82
+ name: 'catalog.list_variants',
83
+ displayName: 'List variants',
84
+ description:
85
+ 'List the variants of a catalog product (including option values, SKU, barcode, default media ref). Returns { items, total, limit, offset }.',
86
+ inputSchema: listVariantsInput,
87
+ requiredFeatures: ['catalog.products.view'],
88
+ toOperation: (input, ctx) => {
89
+ assertTenantScope(ctx as unknown as CatalogToolContext)
90
+ const limit = input.limit ?? 50
91
+ const offset = input.offset ?? 0
92
+ const page = Math.floor(offset / limit) + 1
93
+
94
+ const query: Record<string, string | number | boolean | null | undefined> = {
95
+ page,
96
+ pageSize: limit,
97
+ productId: input.productId,
98
+ }
99
+
100
+ const operation: AiApiOperationRequest = {
101
+ method: 'GET',
102
+ path: '/catalog/variants',
103
+ query,
104
+ }
105
+ return operation
106
+ },
107
+ mapResponse: (response, input) => {
108
+ const limit = input.limit ?? 50
109
+ const offset = input.offset ?? 0
110
+ const data = (response.data ?? {}) as ListVariantsApiResponse
111
+ const rawItems: ListVariantsApiItem[] = Array.isArray(data.items) ? data.items : []
112
+ return {
113
+ items: rawItems.map((row) => {
114
+ const createdAtRaw = row.created_at ?? row.createdAt ?? null
115
+ const createdAt = createdAtRaw ? new Date(String(createdAtRaw)).toISOString() : null
116
+ return {
117
+ id: row.id,
118
+ name: row.name ?? null,
119
+ sku: row.sku ?? null,
120
+ barcode: row.barcode ?? null,
121
+ statusEntryId: row.status_entry_id ?? row.statusEntryId ?? null,
122
+ optionValues: row.option_values ?? row.optionValues ?? null,
123
+ defaultMediaId: row.default_media_id ?? row.defaultMediaId ?? null,
124
+ defaultMediaUrl: row.default_media_url ?? row.defaultMediaUrl ?? null,
125
+ weightValue: row.weight_value ?? row.weightValue ?? null,
126
+ weightUnit: row.weight_unit ?? row.weightUnit ?? null,
127
+ dimensions: row.dimensions ?? null,
128
+ taxRate: row.tax_rate ?? row.taxRate ?? null,
129
+ taxRateId: row.tax_rate_id ?? row.taxRateId ?? null,
130
+ isDefault: !!(row.is_default ?? row.isDefault),
131
+ isActive: !!(row.is_active ?? row.isActive),
132
+ productId: row.product_id ?? row.productId ?? null,
133
+ organizationId: row.organization_id ?? row.organizationId ?? null,
134
+ tenantId: row.tenant_id ?? row.tenantId ?? null,
135
+ createdAt,
136
+ }
137
+ }),
138
+ total: typeof data.total === 'number' ? data.total : 0,
139
+ limit,
140
+ offset,
141
+ }
142
+ },
143
+ }) as unknown as CatalogAiToolDefinition
144
+
145
+ export const variantsAiTools: CatalogAiToolDefinition[] = [listVariantsTool]
146
+
147
+ export default variantsAiTools