@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,608 @@
1
+ /**
2
+ * D18 catalog merchandising read tools (Phase 1 WS-C, Step 3.11).
3
+ *
4
+ * Ships the seven canonical tool names the `catalog.merchandising_assistant`
5
+ * agent (Step 4.9) whitelists verbatim:
6
+ *
7
+ * - `catalog.search_products` — fulltext + filter search (hybrid path).
8
+ * - `catalog.get_product_bundle` — aggregate bundle for a single product.
9
+ * - `catalog.list_selected_products` — bundle aggregate for an ID array.
10
+ * - `catalog.get_product_media` — media metadata (attachment IDs only —
11
+ * the Step 3.7 attachment bridge converts these to model file parts at
12
+ * runtime invocation; this tool does NOT call the bridge directly).
13
+ * - `catalog.get_attribute_schema` — merged module + category + product
14
+ * custom-field schema.
15
+ * - `catalog.get_category_brief` — category snapshot with inherited schema.
16
+ * - `catalog.list_price_kinds` — D18 spec-named price-kind enumerator.
17
+ *
18
+ * Every tool is read-only (no `isMutation: true`). Mutation tooling for D18
19
+ * lands in Step 5.14 under the pending-action contract.
20
+ *
21
+ * Tenant scoping: all DB access uses `findWithDecryption` /
22
+ * `findOneWithDecryption`, plus a defensive post-filter against
23
+ * `row.tenantId === ctx.tenantId`. Cross-tenant IDs surface through the
24
+ * `missingIds` output (not an error), so a chat agent receives a uniform
25
+ * not-found signal whether the product is missing, deleted, or out of scope.
26
+ *
27
+ * Shared helpers:
28
+ * `list_price_kinds` + Step 3.10's `list_price_kinds_base` both route
29
+ * through `listPriceKindsCore` in `./_shared.ts`; there is no duplicate
30
+ * query path.
31
+ *
32
+ * Step 3.12 (authoring pack) promoted the product-bundle builder plus
33
+ * `toProductSummary` / `resolveAttributeSchema` into `./_shared.ts` so
34
+ * both packs consume the same loader. This file now just wires the
35
+ * shared helpers into tool handlers.
36
+ */
37
+ import type { EntityManager } from '@mikro-orm/postgresql'
38
+ import { z } from 'zod'
39
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
40
+ import { type QueryEngine, type QueryResult, SortDir } from '@open-mercato/shared/lib/query/types'
41
+ import { E } from '#generated/entities.ids.generated'
42
+ import { Attachment } from '@open-mercato/core/modules/attachments/data/entities'
43
+ import {
44
+ CatalogProduct,
45
+ CatalogProductCategory,
46
+ CatalogProductCategoryAssignment,
47
+ CatalogProductPrice,
48
+ CatalogProductTag,
49
+ CatalogProductTagAssignment,
50
+ } from '../data/entities'
51
+ import { assertTenantScope, type CatalogAiToolDefinition, type CatalogToolContext } from './types'
52
+ import {
53
+ buildProductBundle,
54
+ buildScope,
55
+ listPriceKindsCore,
56
+ resolveAttributeSchema,
57
+ resolveEm,
58
+ toPriceNumeric,
59
+ toProductSummary,
60
+ type ProductBundle,
61
+ type ProductBundleResult,
62
+ } from './_shared'
63
+
64
+ type SearchServiceLike = {
65
+ search: (query: string, options: {
66
+ tenantId: string
67
+ organizationId?: string | null
68
+ limit?: number
69
+ entityTypes?: string[]
70
+ }) => Promise<Array<{
71
+ entityId: string
72
+ recordId: string
73
+ score: number
74
+ source: string
75
+ presenter?: unknown
76
+ url?: string
77
+ }>>
78
+ }
79
+
80
+ const CATALOG_PRODUCT_ENTITY = 'catalog:catalog_product'
81
+
82
+ function resolveSearchService(ctx: CatalogToolContext): SearchServiceLike | null {
83
+ try {
84
+ return ctx.container.resolve<SearchServiceLike>('searchService')
85
+ } catch {
86
+ return null
87
+ }
88
+ }
89
+
90
+ /* -------------------------------------------------------------------------- */
91
+ /* catalog.search_products */
92
+ /* -------------------------------------------------------------------------- */
93
+
94
+ const searchProductsInput = z
95
+ .object({
96
+ q: z.string().trim().optional().describe('Optional fulltext query (title / subtitle / sku / handle). Omit or leave empty (or do NOT pass it at all) to list all products. NEVER use "*", "**", or "%" — they are not wildcards and will be discarded.'),
97
+ limit: z.number().int().min(1).max(100).optional().describe('Page size. Default 50, hard maximum 100. Results are paginated: when `total` exceeds `limit + offset`, call again with the next `offset` instead of asking for more than 100 rows.'),
98
+ offset: z.number().int().min(0).optional().describe('Rows to skip for pagination (default 0). Combine with `limit` to fetch subsequent pages — for example offset=100, limit=100 fetches rows 101..200.'),
99
+ categoryId: z.string().optional().describe('Restrict to products assigned to this catalog category UUID. Only use a category ID returned by a previous tool call — do NOT guess. Empty string is treated the same as omitting the field.'),
100
+ priceMin: z.number().optional().describe('Lower-bound (inclusive) on the gross unit price. OMIT this field when you do not want a lower bound — do NOT pass 0 as "no minimum", because 0 is a real bound that combined with priceMax=0 will return only free products.'),
101
+ priceMax: z.number().optional().describe('Upper-bound (inclusive) on the gross unit price. OMIT this field when you do not want an upper bound — do NOT pass 0 as "no maximum", because 0 is a real bound that will exclude every priced product.'),
102
+ tags: z.array(z.string()).optional().describe('Tag labels or slugs (any-match) the product carries. Omit or pass an empty array to skip the tag filter.'),
103
+ active: z.boolean().optional().describe('When true, only active products are returned. Omit to include inactive products as well.'),
104
+ })
105
+ .passthrough()
106
+
107
+ type SearchProductsInput = z.infer<typeof searchProductsInput>
108
+
109
+ async function queryProductsWithFilters(
110
+ em: EntityManager,
111
+ ctx: CatalogToolContext,
112
+ tenantId: string,
113
+ input: SearchProductsInput,
114
+ restrictToIds: string[] | null,
115
+ ): Promise<{ items: ReturnType<typeof toProductSummary>[]; total: number }> {
116
+ const limit = input.limit ?? 50
117
+ const offset = input.offset ?? 0
118
+
119
+ let idRestriction: string[] | null = restrictToIds
120
+ ? Array.from(new Set(restrictToIds))
121
+ : null
122
+
123
+ if (input.categoryId) {
124
+ const assignments = await findWithDecryption<CatalogProductCategoryAssignment>(
125
+ em,
126
+ CatalogProductCategoryAssignment,
127
+ { tenantId, category: input.categoryId } as any,
128
+ undefined,
129
+ buildScope(ctx, tenantId),
130
+ )
131
+ const ids = assignments
132
+ .map((assignment) => {
133
+ const product = (assignment as any).product
134
+ if (!product) return null
135
+ return typeof product === 'string' ? product : product.id ?? null
136
+ })
137
+ .filter((value: string | null): value is string => typeof value === 'string' && value.length > 0)
138
+ if (!ids.length) return { items: [], total: 0 }
139
+ idRestriction = idRestriction
140
+ ? idRestriction.filter((id) => ids.includes(id))
141
+ : ids
142
+ if (!idRestriction.length) return { items: [], total: 0 }
143
+ }
144
+
145
+ if (input.tags && input.tags.length > 0) {
146
+ const tagWhere: Record<string, unknown> = {
147
+ tenantId,
148
+ $or: [
149
+ { slug: { $in: input.tags } },
150
+ { label: { $in: input.tags } },
151
+ ],
152
+ }
153
+ if (ctx.organizationId) tagWhere.organizationId = ctx.organizationId
154
+ const tags = await findWithDecryption<CatalogProductTag>(
155
+ em,
156
+ CatalogProductTag,
157
+ tagWhere as any,
158
+ undefined,
159
+ buildScope(ctx, tenantId),
160
+ )
161
+ const tagIds = tags.map((tag) => tag.id)
162
+ if (!tagIds.length) return { items: [], total: 0 }
163
+ const assignments = await findWithDecryption<CatalogProductTagAssignment>(
164
+ em,
165
+ CatalogProductTagAssignment,
166
+ { tenantId, tag: { $in: tagIds } } as any,
167
+ undefined,
168
+ buildScope(ctx, tenantId),
169
+ )
170
+ const scopedIds = assignments
171
+ .map((assignment) => {
172
+ const product = (assignment as any).product
173
+ if (!product) return null
174
+ return typeof product === 'string' ? product : product.id ?? null
175
+ })
176
+ .filter((value: string | null): value is string => typeof value === 'string' && value.length > 0)
177
+ if (!scopedIds.length) return { items: [], total: 0 }
178
+ idRestriction = idRestriction
179
+ ? idRestriction.filter((id) => scopedIds.includes(id))
180
+ : scopedIds
181
+ if (!idRestriction.length) return { items: [], total: 0 }
182
+ }
183
+
184
+ if (input.priceMin !== undefined || input.priceMax !== undefined) {
185
+ const priceWhere: Record<string, unknown> = { tenantId }
186
+ if (ctx.organizationId) priceWhere.organizationId = ctx.organizationId
187
+ const priceRows = await findWithDecryption<CatalogProductPrice>(
188
+ em,
189
+ CatalogProductPrice,
190
+ priceWhere as any,
191
+ undefined,
192
+ buildScope(ctx, tenantId),
193
+ )
194
+ const bounded = priceRows.filter((row) => {
195
+ const net = toPriceNumeric(row.unitPriceNet ?? null)
196
+ const gross = toPriceNumeric(row.unitPriceGross ?? null)
197
+ const comparable = gross ?? net
198
+ if (comparable === null) return false
199
+ if (input.priceMin !== undefined && comparable < input.priceMin) return false
200
+ if (input.priceMax !== undefined && comparable > input.priceMax) return false
201
+ return true
202
+ })
203
+ const scopedIds = bounded
204
+ .map((row) => {
205
+ const product = (row as any).product
206
+ if (!product) return null
207
+ return typeof product === 'string' ? product : product.id ?? null
208
+ })
209
+ .filter((value: string | null): value is string => typeof value === 'string' && value.length > 0)
210
+ if (!scopedIds.length) return { items: [], total: 0 }
211
+ idRestriction = idRestriction
212
+ ? idRestriction.filter((id) => scopedIds.includes(id))
213
+ : Array.from(new Set(scopedIds))
214
+ if (!idRestriction.length) return { items: [], total: 0 }
215
+ }
216
+
217
+ const filters: Record<string, unknown> = {}
218
+ if (input.active === true) filters.is_active = true
219
+ if (input.q) {
220
+ const pattern = `%${input.q}%`
221
+ filters.$or = [
222
+ { title: { $ilike: pattern } },
223
+ { subtitle: { $ilike: pattern } },
224
+ { sku: { $ilike: pattern } },
225
+ { handle: { $ilike: pattern } },
226
+ ]
227
+ }
228
+ if (idRestriction) {
229
+ filters.id = { $in: idRestriction }
230
+ }
231
+
232
+ const qe = ctx.container.resolve<QueryEngine>('queryEngine')
233
+ const result: QueryResult = await qe.query('catalog:catalog_product', {
234
+ filters,
235
+ sort: [{ field: 'created_at', dir: SortDir.Desc }],
236
+ page: { page: Math.floor(offset / limit) + 1, pageSize: limit },
237
+ tenantId,
238
+ organizationId: ctx.organizationId ?? undefined,
239
+ })
240
+
241
+ const productIds = result.items
242
+ .map((row: Record<string, unknown>) => row.id as string)
243
+ .filter((id): id is string => typeof id === 'string')
244
+ if (!productIds.length) return { items: [], total: result.total }
245
+
246
+ const products = await findWithDecryption<CatalogProduct>(
247
+ em,
248
+ CatalogProduct,
249
+ { tenantId, id: { $in: productIds }, deletedAt: null } as any,
250
+ undefined,
251
+ buildScope(ctx, tenantId),
252
+ )
253
+ const productById = new Map(products.map((p) => [p.id, p]))
254
+ const ordered = productIds
255
+ .map((id) => productById.get(id))
256
+ .filter((p): p is CatalogProduct => !!p)
257
+
258
+ return { items: ordered.map(toProductSummary), total: result.total }
259
+ }
260
+
261
+ const searchProductsTool: CatalogAiToolDefinition = {
262
+ name: 'catalog.search_products',
263
+ displayName: 'Search products',
264
+ description:
265
+ 'Hybrid search + filter across tenant products. When `q` is non-empty, routes through the search service (tenant + organization scoped) then hydrates tenant-scoped product summaries; when `q` is empty or omitted, runs the catalog query engine with the supplied filters and returns ALL products. ' +
266
+ 'Pagination: response shape is `{ items, total, limit, offset, source }`. Hard cap is 100 rows per call (`limit` max=100, default=50); if `total` exceeds `limit + offset`, call again with the next `offset` rather than asking for >100. ' +
267
+ 'Empty / sentinel inputs: pass an empty `q` (or omit it) to list all products. NEVER pass `0` for `priceMin`/`priceMax` to mean "no bound" — `0` is a real numeric bound (priceMin=0 + priceMax=0 returns only free products). To remove a bound, OMIT the field. Empty string `categoryId` and empty `tags` arrays are ignored. ' +
268
+ '`source` in the output indicates which path executed (`search_service` vs `query_engine`).',
269
+ inputSchema: searchProductsInput,
270
+ requiredFeatures: ['catalog.products.view'],
271
+ tags: ['read', 'catalog', 'merchandising'],
272
+ isMutation: false,
273
+ handler: async (rawInput, ctx) => {
274
+ const { tenantId } = assertTenantScope(ctx)
275
+ const input = searchProductsInput.parse(rawInput)
276
+ const em = resolveEm(ctx)
277
+ const limit = input.limit ?? 50
278
+ const offset = input.offset ?? 0
279
+
280
+ // Treat wildcard-style and empty queries as "list all".
281
+ if (!input.q || input.q === '*' || input.q === '**' || input.q === '%') {
282
+ input.q = undefined
283
+ }
284
+
285
+ // Empty-string sentinel for `categoryId` is the agent's way of saying
286
+ // "no category filter" — normalize before the UUID guard runs.
287
+ if (typeof input.categoryId === 'string' && input.categoryId.trim() === '') {
288
+ input.categoryId = undefined
289
+ }
290
+
291
+ // Empty `tags` array is meaningless as a filter; drop it so the price /
292
+ // category narrowing path doesn't execute a no-op tag join.
293
+ if (Array.isArray(input.tags) && input.tags.length === 0) {
294
+ input.tags = undefined
295
+ }
296
+
297
+ // Agents commonly pass `priceMin: 0` / `priceMax: 0` thinking 0 means
298
+ // "no bound". It does not — 0 is a real inclusive bound and the two
299
+ // together ask for free products only. Treat the (0, 0) pair as
300
+ // "unset" so the empty-bound default carries through. A standalone 0
301
+ // (e.g. priceMin=0 with priceMax=50) is left intact because it can be
302
+ // a meaningful "everything from free up to 50" filter.
303
+ if (
304
+ input.priceMin === 0 &&
305
+ input.priceMax === 0
306
+ ) {
307
+ input.priceMin = undefined
308
+ input.priceMax = undefined
309
+ }
310
+
311
+ // Guard against hallucinated or invalid category IDs.
312
+ if (input.categoryId) {
313
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
314
+ if (!uuidPattern.test(input.categoryId)) {
315
+ input.categoryId = undefined
316
+ } else {
317
+ try {
318
+ const catExists = await em.count(CatalogProductCategory, { id: input.categoryId, tenantId } as any)
319
+ if (catExists === 0) input.categoryId = undefined
320
+ } catch {
321
+ input.categoryId = undefined
322
+ }
323
+ }
324
+ }
325
+
326
+ if (input.q && input.q.trim().length > 0) {
327
+ const service = resolveSearchService(ctx)
328
+ if (service) {
329
+ const hits = await service.search(input.q.trim(), {
330
+ tenantId,
331
+ organizationId: ctx.organizationId,
332
+ limit,
333
+ entityTypes: [CATALOG_PRODUCT_ENTITY],
334
+ })
335
+ const hitIds = hits
336
+ .filter((hit) => hit.entityId === CATALOG_PRODUCT_ENTITY)
337
+ .map((hit) => hit.recordId)
338
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
339
+ if (!hitIds.length) {
340
+ return { items: [], total: 0, limit, offset, source: 'search_service' as const }
341
+ }
342
+ // Hydrate tenant-scoped products from the hit IDs, then apply any
343
+ // structured filters the search service can't express (category /
344
+ // price / tags / active). Search services in this repo do not
345
+ // currently accept structured filters, so we narrow in-process and
346
+ // document that in the return report + description.
347
+ const { items, total } = await queryProductsWithFilters(em, ctx, tenantId, { ...input, q: undefined }, hitIds)
348
+ return { items, total, limit, offset, source: 'search_service' as const }
349
+ }
350
+ // Fall through to the query-engine path if the search service is not
351
+ // registered in the DI container — keeps the tool usable in test
352
+ // harnesses and during early bring-up.
353
+ }
354
+
355
+ const { items, total } = await queryProductsWithFilters(em, ctx, tenantId, input, null)
356
+ return { items, total, limit, offset, source: 'query_engine' as const }
357
+ },
358
+ }
359
+
360
+ /* -------------------------------------------------------------------------- */
361
+ /* catalog.get_product_bundle / catalog.list_selected_products */
362
+ /* -------------------------------------------------------------------------- */
363
+
364
+ const getProductBundleInput = z.object({
365
+ productId: z.string().uuid().describe('Catalog product id (UUID).'),
366
+ })
367
+
368
+ const getProductBundleTool: CatalogAiToolDefinition = {
369
+ name: 'catalog.get_product_bundle',
370
+ displayName: 'Get product bundle',
371
+ description:
372
+ 'Aggregate product snapshot for D18 merchandising: core fields + categories + tags + variants + prices (base + best via pricing service) + media metadata + custom-field values + merged attribute schema. Media bytes flow through the attachment bridge (use `catalog.get_product_media` then the bridge). Returns `{ found: false }` on miss or cross-tenant access.',
373
+ inputSchema: getProductBundleInput,
374
+ requiredFeatures: ['catalog.products.view'],
375
+ tags: ['read', 'catalog', 'merchandising'],
376
+ isMutation: false,
377
+ handler: async (rawInput, ctx) => {
378
+ const { tenantId } = assertTenantScope(ctx)
379
+ const input = getProductBundleInput.parse(rawInput)
380
+ const em = resolveEm(ctx)
381
+ return buildProductBundle(em, ctx, tenantId, input.productId)
382
+ },
383
+ }
384
+
385
+ const listSelectedProductsInput = z.object({
386
+ productIds: z
387
+ .array(z.string().uuid())
388
+ .min(1)
389
+ .max(50)
390
+ .describe('1..50 catalog product ids (UUIDs). Duplicates are collapsed; cross-tenant ids drop into `missingIds`.'),
391
+ })
392
+
393
+ const listSelectedProductsTool: CatalogAiToolDefinition = {
394
+ name: 'catalog.list_selected_products',
395
+ displayName: 'List selected products (bundles)',
396
+ description:
397
+ 'Bulk variant of `catalog.get_product_bundle`: resolves 1..50 product ids into tenant-scoped bundle aggregates. Missing / cross-tenant ids are returned in `missingIds` (not as an error) so selection-aware agents can render partial results.',
398
+ inputSchema: listSelectedProductsInput,
399
+ requiredFeatures: ['catalog.products.view'],
400
+ tags: ['read', 'catalog', 'merchandising'],
401
+ isMutation: false,
402
+ handler: async (rawInput, ctx) => {
403
+ const { tenantId } = assertTenantScope(ctx)
404
+ const input = listSelectedProductsInput.parse(rawInput)
405
+ const em = resolveEm(ctx)
406
+ const unique = Array.from(new Set(input.productIds))
407
+ const resolved = await Promise.all(
408
+ unique.map(async (productId) => ({ productId, result: await buildProductBundle(em, ctx, tenantId, productId) })),
409
+ )
410
+ const items: ProductBundle[] = []
411
+ const missingIds: string[] = []
412
+ for (const entry of resolved) {
413
+ if (entry.result.found) {
414
+ items.push(entry.result)
415
+ } else {
416
+ missingIds.push(entry.productId)
417
+ console.warn(`[catalog.list_selected_products] product not in scope: ${entry.productId}`)
418
+ }
419
+ }
420
+ return { items, missingIds }
421
+ },
422
+ }
423
+
424
+ /* -------------------------------------------------------------------------- */
425
+ /* catalog.get_product_media */
426
+ /* -------------------------------------------------------------------------- */
427
+
428
+ const getProductMediaInput = z.object({
429
+ productId: z.string().uuid().describe('Catalog product id (UUID).'),
430
+ limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),
431
+ offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),
432
+ })
433
+
434
+ const getProductMediaTool: CatalogAiToolDefinition = {
435
+ name: 'catalog.get_product_media',
436
+ displayName: 'Get product media (with attachment IDs)',
437
+ description:
438
+ 'List media records attached to a product with metadata (filename, mime, size, sort order) and the `attachmentId` string for each row. Does NOT invoke the attachment bridge — the Step 3.7 runtime bridge converts attachment ids into model file parts when the chat/object helper invokes this tool in-context. Returns `{ items, total, limit, offset }`.',
439
+ inputSchema: getProductMediaInput,
440
+ requiredFeatures: ['catalog.products.view'],
441
+ tags: ['read', 'catalog', 'merchandising'],
442
+ isMutation: false,
443
+ handler: async (rawInput, ctx) => {
444
+ const { tenantId } = assertTenantScope(ctx)
445
+ const input = getProductMediaInput.parse(rawInput)
446
+ const em = resolveEm(ctx)
447
+ const limit = input.limit ?? 50
448
+ const offset = input.offset ?? 0
449
+ const where: Record<string, unknown> = {
450
+ tenantId,
451
+ entityId: E.catalog.catalog_product,
452
+ recordId: input.productId,
453
+ }
454
+ if (ctx.organizationId) where.organizationId = ctx.organizationId
455
+ const [rows, total] = await Promise.all([
456
+ findWithDecryption<Attachment>(
457
+ em,
458
+ Attachment,
459
+ where as any,
460
+ { limit, offset, orderBy: { createdAt: 'asc' } as any } as any,
461
+ buildScope(ctx, tenantId),
462
+ ),
463
+ em.count(Attachment, where as any),
464
+ ])
465
+ const filtered = rows.filter((row) => (row.tenantId ?? null) === tenantId)
466
+ return {
467
+ items: filtered.map((row) => ({
468
+ mediaId: row.id,
469
+ productId: input.productId,
470
+ attachmentId: row.id,
471
+ fileName: row.fileName,
472
+ mediaType: row.mimeType,
473
+ size: row.fileSize,
474
+ altText: null,
475
+ sortOrder: 0,
476
+ })),
477
+ total,
478
+ limit,
479
+ offset,
480
+ }
481
+ },
482
+ }
483
+
484
+ /* -------------------------------------------------------------------------- */
485
+ /* catalog.get_attribute_schema / catalog.get_category_brief */
486
+ /* -------------------------------------------------------------------------- */
487
+
488
+ const getAttributeSchemaInput = z.object({
489
+ productId: z.string().uuid().optional().describe('Narrow schema resolution to this product (scope: `product`).'),
490
+ categoryId: z.string().uuid().optional().describe('Narrow schema resolution to this category (scope: `category`).'),
491
+ })
492
+
493
+ const getAttributeSchemaTool: CatalogAiToolDefinition = {
494
+ name: 'catalog.get_attribute_schema',
495
+ displayName: 'Get attribute schema',
496
+ description:
497
+ 'Resolve the merged custom-field attribute schema for catalog products. When both `productId` and `categoryId` are absent, returns module-level fields only. Reuses the shared `loadCustomFieldDefinitionIndex` resolver so tenant + organization scoping rules stay consistent with CrudForm / admin routes.',
498
+ inputSchema: getAttributeSchemaInput,
499
+ requiredFeatures: ['catalog.products.view'],
500
+ tags: ['read', 'catalog', 'merchandising'],
501
+ isMutation: false,
502
+ handler: async (rawInput, ctx) => {
503
+ const { tenantId } = assertTenantScope(ctx)
504
+ const input = getAttributeSchemaInput.parse(rawInput)
505
+ return resolveAttributeSchema(ctx, tenantId, input.productId, input.categoryId)
506
+ },
507
+ }
508
+
509
+ const getCategoryBriefInput = z.object({
510
+ categoryId: z.string().uuid().describe('Category id (UUID).'),
511
+ })
512
+
513
+ const getCategoryBriefTool: CatalogAiToolDefinition = {
514
+ name: 'catalog.get_category_brief',
515
+ displayName: 'Get category brief',
516
+ description:
517
+ 'Category name, full tree path, description, and merged attribute schema (same resolver as `catalog.get_attribute_schema` with `categoryId`). Returns `{ found: false }` on miss / cross-tenant.',
518
+ inputSchema: getCategoryBriefInput,
519
+ requiredFeatures: ['catalog.categories.view'],
520
+ tags: ['read', 'catalog', 'merchandising'],
521
+ isMutation: false,
522
+ handler: async (rawInput, ctx) => {
523
+ const { tenantId } = assertTenantScope(ctx)
524
+ const input = getCategoryBriefInput.parse(rawInput)
525
+ const em = resolveEm(ctx)
526
+ const where: Record<string, unknown> = {
527
+ id: input.categoryId,
528
+ tenantId,
529
+ deletedAt: null,
530
+ }
531
+ if (ctx.organizationId) where.organizationId = ctx.organizationId
532
+ const category = await findOneWithDecryption<CatalogProductCategory>(
533
+ em,
534
+ CatalogProductCategory,
535
+ where as any,
536
+ undefined,
537
+ buildScope(ctx, tenantId),
538
+ )
539
+ if (!category || category.tenantId !== tenantId) {
540
+ return { found: false as const, categoryId: input.categoryId }
541
+ }
542
+ const attributeSchema = await resolveAttributeSchema(ctx, tenantId, undefined, category.id)
543
+ return {
544
+ found: true as const,
545
+ id: category.id,
546
+ name: category.name,
547
+ path: category.treePath ?? null,
548
+ description: category.description ?? null,
549
+ attributeSchema,
550
+ }
551
+ },
552
+ }
553
+
554
+ /* -------------------------------------------------------------------------- */
555
+ /* catalog.list_price_kinds */
556
+ /* -------------------------------------------------------------------------- */
557
+
558
+ const listPriceKindsInput = z
559
+ .object({
560
+ limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),
561
+ offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),
562
+ })
563
+ .passthrough()
564
+
565
+ const listPriceKindsTool: CatalogAiToolDefinition = {
566
+ name: 'catalog.list_price_kinds',
567
+ displayName: 'List price kinds',
568
+ description:
569
+ 'Enumerate tenant price kinds for the D18 merchandising assistant. Shares the tenant-scoped query path with `catalog.list_price_kinds_base`; the two tools differ only in description/framing (the base tool is the low-level settings enumerator, this one is the spec-named D18 surface).',
570
+ inputSchema: listPriceKindsInput,
571
+ requiredFeatures: ['catalog.settings.manage'],
572
+ tags: ['read', 'catalog', 'merchandising'],
573
+ isMutation: false,
574
+ handler: async (rawInput, ctx) => {
575
+ const { tenantId } = assertTenantScope(ctx)
576
+ const input = listPriceKindsInput.parse(rawInput)
577
+ const base = await listPriceKindsCore(ctx, input, tenantId)
578
+ return {
579
+ items: base.items.map((row) => ({
580
+ id: row.id,
581
+ code: row.code,
582
+ name: row.title,
583
+ scope: row.organizationId ? ('organization' as const) : ('tenant' as const),
584
+ currency: row.currencyCode,
585
+ appliesTo: row.isPromotion ? ('promotion' as const) : ('regular' as const),
586
+ })),
587
+ total: base.total,
588
+ limit: base.limit,
589
+ offset: base.offset,
590
+ }
591
+ },
592
+ }
593
+
594
+ /* -------------------------------------------------------------------------- */
595
+ /* Export */
596
+ /* -------------------------------------------------------------------------- */
597
+
598
+ export const merchandisingAiTools: CatalogAiToolDefinition[] = [
599
+ searchProductsTool,
600
+ getProductBundleTool,
601
+ listSelectedProductsTool,
602
+ getProductMediaTool,
603
+ getAttributeSchemaTool,
604
+ getCategoryBriefTool,
605
+ listPriceKindsTool,
606
+ ]
607
+
608
+ export default merchandisingAiTools