@open-mercato/core 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3043.1a796c3920

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,192 @@
1
+ /**
2
+ * `catalog.list_categories` + `catalog.get_category` (Phase 1 WS-C, Step 3.10).
3
+ *
4
+ * Read-only category tools scoped by tenant + organization. `parentId: null`
5
+ * returns root nodes; any concrete UUID restricts to direct children.
6
+ */
7
+ import type { EntityManager } from '@mikro-orm/postgresql'
8
+ import { z } from 'zod'
9
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
10
+ import { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'
11
+ import { E } from '#generated/entities.ids.generated'
12
+ import { CatalogProductCategory } from '../data/entities'
13
+ import { assertTenantScope, type CatalogAiToolDefinition, type CatalogToolContext } from './types'
14
+
15
+ function resolveEm(ctx: CatalogToolContext): EntityManager {
16
+ return ctx.container.resolve<EntityManager>('em')
17
+ }
18
+
19
+ function buildScope(ctx: CatalogToolContext, tenantId: string) {
20
+ return { tenantId, organizationId: ctx.organizationId }
21
+ }
22
+
23
+ const listCategoriesInput = z
24
+ .object({
25
+ parentId: z
26
+ .union([z.string().uuid(), z.null()])
27
+ .optional()
28
+ .describe('Parent category id; pass `null` to list root nodes. Omit to list every category in scope.'),
29
+ limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),
30
+ offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),
31
+ includeArchived: z
32
+ .boolean()
33
+ .optional()
34
+ .describe('When true, include soft-deleted categories. Defaults to active-only.'),
35
+ })
36
+ .passthrough()
37
+
38
+ const listCategoriesTool: CatalogAiToolDefinition = {
39
+ name: 'catalog.list_categories',
40
+ displayName: 'List categories',
41
+ description:
42
+ 'List catalog categories scoped to tenant + organization. Use `parentId: null` to list roots or a specific uuid to fetch direct children.',
43
+ inputSchema: listCategoriesInput,
44
+ requiredFeatures: ['catalog.categories.view'],
45
+ tags: ['read', 'catalog'],
46
+ handler: async (rawInput, ctx) => {
47
+ const { tenantId } = assertTenantScope(ctx)
48
+ const input = listCategoriesInput.parse(rawInput)
49
+ const em = resolveEm(ctx)
50
+ const limit = input.limit ?? 50
51
+ const offset = input.offset ?? 0
52
+ const where: Record<string, unknown> = { tenantId }
53
+ if (ctx.organizationId) where.organizationId = ctx.organizationId
54
+ if (!input.includeArchived) where.deletedAt = null
55
+ if ('parentId' in input) {
56
+ where.parentId = input.parentId ?? null
57
+ }
58
+ const [rows, total] = await Promise.all([
59
+ findWithDecryption<CatalogProductCategory>(
60
+ em,
61
+ CatalogProductCategory,
62
+ where as any,
63
+ { limit, offset, orderBy: { depth: 'asc', name: 'asc' } as any } as any,
64
+ buildScope(ctx, tenantId),
65
+ ),
66
+ em.count(CatalogProductCategory, where as any),
67
+ ])
68
+ const filtered = rows.filter((row) => row.tenantId === tenantId)
69
+ return {
70
+ items: filtered.map((row) => ({
71
+ id: row.id,
72
+ name: row.name,
73
+ slug: row.slug ?? null,
74
+ description: row.description ?? null,
75
+ parentId: row.parentId ?? null,
76
+ rootId: row.rootId ?? null,
77
+ treePath: row.treePath ?? null,
78
+ depth: row.depth,
79
+ childIds: Array.isArray(row.childIds) ? row.childIds : [],
80
+ isActive: !!row.isActive,
81
+ organizationId: row.organizationId ?? null,
82
+ tenantId: row.tenantId ?? null,
83
+ createdAt: row.createdAt ? new Date(row.createdAt).toISOString() : null,
84
+ })),
85
+ total,
86
+ limit,
87
+ offset,
88
+ }
89
+ },
90
+ }
91
+
92
+ const getCategoryInput = z.object({
93
+ categoryId: z.string().uuid().describe('Category id (UUID).'),
94
+ includeRelated: z
95
+ .boolean()
96
+ .optional()
97
+ .describe(
98
+ 'When true, include direct children (capped at 100) and inherited ancestor refs. Custom fields are always included.',
99
+ ),
100
+ })
101
+
102
+ const getCategoryTool: CatalogAiToolDefinition = {
103
+ name: 'catalog.get_category',
104
+ displayName: 'Get category',
105
+ description:
106
+ 'Fetch a catalog category by id with core fields and (optionally) children + ancestor inheritance + custom fields. Returns { found: false } when missing or cross-tenant.',
107
+ inputSchema: getCategoryInput,
108
+ requiredFeatures: ['catalog.categories.view'],
109
+ tags: ['read', 'catalog'],
110
+ handler: async (rawInput, ctx) => {
111
+ const { tenantId } = assertTenantScope(ctx)
112
+ const input = getCategoryInput.parse(rawInput)
113
+ const em = resolveEm(ctx)
114
+ const where: Record<string, unknown> = {
115
+ id: input.categoryId,
116
+ tenantId,
117
+ deletedAt: null,
118
+ }
119
+ if (ctx.organizationId) where.organizationId = ctx.organizationId
120
+ const category = await findOneWithDecryption<CatalogProductCategory>(
121
+ em,
122
+ CatalogProductCategory,
123
+ where as any,
124
+ undefined,
125
+ buildScope(ctx, tenantId),
126
+ )
127
+ if (!category || category.tenantId !== tenantId) {
128
+ return { found: false as const, categoryId: input.categoryId }
129
+ }
130
+ const customFieldValues = await loadCustomFieldValues({
131
+ em,
132
+ entityId: E.catalog.catalog_product_category,
133
+ recordIds: [category.id],
134
+ tenantIdByRecord: { [category.id]: category.tenantId ?? null },
135
+ organizationIdByRecord: { [category.id]: category.organizationId ?? null },
136
+ tenantFallbacks: [category.tenantId ?? tenantId].filter((value): value is string => !!value),
137
+ })
138
+ const customFields = customFieldValues[category.id] ?? {}
139
+ let related: Record<string, unknown> | null = null
140
+ if (input.includeRelated) {
141
+ const scope = buildScope(ctx, tenantId)
142
+ const children = await findWithDecryption<CatalogProductCategory>(
143
+ em,
144
+ CatalogProductCategory,
145
+ { tenantId, parentId: category.id, deletedAt: null } as any,
146
+ { limit: 100, orderBy: { name: 'asc' } as any } as any,
147
+ scope,
148
+ )
149
+ related = {
150
+ children: children
151
+ .filter((row) => row.tenantId === tenantId)
152
+ .map((row) => ({
153
+ id: row.id,
154
+ name: row.name,
155
+ slug: row.slug ?? null,
156
+ depth: row.depth,
157
+ isActive: !!row.isActive,
158
+ })),
159
+ ancestorIds: Array.isArray(category.ancestorIds) ? [...category.ancestorIds] : [],
160
+ descendantIds: Array.isArray(category.descendantIds) ? [...category.descendantIds] : [],
161
+ }
162
+ }
163
+ return {
164
+ found: true as const,
165
+ category: {
166
+ id: category.id,
167
+ name: category.name,
168
+ slug: category.slug ?? null,
169
+ description: category.description ?? null,
170
+ parentId: category.parentId ?? null,
171
+ rootId: category.rootId ?? null,
172
+ treePath: category.treePath ?? null,
173
+ depth: category.depth,
174
+ childIds: Array.isArray(category.childIds) ? [...category.childIds] : [],
175
+ ancestorIds: Array.isArray(category.ancestorIds) ? [...category.ancestorIds] : [],
176
+ descendantIds: Array.isArray(category.descendantIds) ? [...category.descendantIds] : [],
177
+ metadata: category.metadata ?? null,
178
+ isActive: !!category.isActive,
179
+ organizationId: category.organizationId ?? null,
180
+ tenantId: category.tenantId ?? null,
181
+ createdAt: category.createdAt ? new Date(category.createdAt).toISOString() : null,
182
+ updatedAt: category.updatedAt ? new Date(category.updatedAt).toISOString() : null,
183
+ },
184
+ customFields,
185
+ related,
186
+ }
187
+ },
188
+ }
189
+
190
+ export const categoriesAiTools: CatalogAiToolDefinition[] = [listCategoriesTool, getCategoryTool]
191
+
192
+ export default categoriesAiTools
@@ -0,0 +1,218 @@
1
+ /**
2
+ * `catalog.list_option_schemas` + `catalog.list_unit_conversions` (Phase 1
3
+ * WS-C, Step 3.10).
4
+ *
5
+ * Product-configuration surface: option schemas (variant axes) and unit
6
+ * conversions (UoM factors).
7
+ *
8
+ * Phase 3c of `.ai/specs/2026-04-27-ai-tools-api-backed-dry-refactor.md`:
9
+ * both tools are now API-backed wrappers over the documented CRUD list
10
+ * routes (`GET /api/catalog/option-schemas` and
11
+ * `GET /api/catalog/product-unit-conversions`). Tool names, schemas,
12
+ * requiredFeatures, and output shapes are unchanged.
13
+ */
14
+ import { z } from 'zod'
15
+ import { defineApiBackedAiTool } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/api-backed-tool'
16
+ import type {
17
+ AiApiOperationRequest,
18
+ AiToolExecutionContext,
19
+ } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-api-operation-runner'
20
+ import { assertTenantScope, type CatalogAiToolDefinition, type CatalogToolContext } from './types'
21
+
22
+ const listOptionSchemasInput = z
23
+ .object({
24
+ limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),
25
+ offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),
26
+ })
27
+ .passthrough()
28
+
29
+ type ListOptionSchemasInput = z.infer<typeof listOptionSchemasInput>
30
+
31
+ type ListOptionSchemasApiItem = {
32
+ id?: string
33
+ code?: string | null
34
+ name?: string | null
35
+ description?: string | null
36
+ schema?: unknown
37
+ metadata?: unknown
38
+ is_active?: boolean | null
39
+ isActive?: boolean | null
40
+ organization_id?: string | null
41
+ organizationId?: string | null
42
+ tenant_id?: string | null
43
+ tenantId?: string | null
44
+ created_at?: string | null
45
+ createdAt?: string | null
46
+ }
47
+
48
+ type ListOptionSchemasApiResponse = {
49
+ items?: ListOptionSchemasApiItem[]
50
+ total?: number
51
+ }
52
+
53
+ type ListOptionSchemasOutput = {
54
+ items: Array<Record<string, unknown>>
55
+ total: number
56
+ limit: number
57
+ offset: number
58
+ }
59
+
60
+ const listOptionSchemasTool = defineApiBackedAiTool<
61
+ ListOptionSchemasInput,
62
+ ListOptionSchemasApiResponse,
63
+ ListOptionSchemasOutput
64
+ >({
65
+ name: 'catalog.list_option_schemas',
66
+ displayName: 'List option schemas',
67
+ description:
68
+ 'List product option schemas (variant axes, e.g. size/color definitions) for the caller tenant + organization.',
69
+ inputSchema: listOptionSchemasInput,
70
+ requiredFeatures: ['catalog.products.view'],
71
+ toOperation: (input, ctx) => {
72
+ assertTenantScope(ctx as unknown as CatalogToolContext)
73
+ const limit = input.limit ?? 50
74
+ const offset = input.offset ?? 0
75
+ const page = Math.floor(offset / limit) + 1
76
+ const operation: AiApiOperationRequest = {
77
+ method: 'GET',
78
+ path: '/catalog/option-schemas',
79
+ query: { page, pageSize: limit },
80
+ }
81
+ return operation
82
+ },
83
+ mapResponse: (response, input) => {
84
+ const limit = input.limit ?? 50
85
+ const offset = input.offset ?? 0
86
+ const data = (response.data ?? {}) as ListOptionSchemasApiResponse
87
+ const rawItems: ListOptionSchemasApiItem[] = Array.isArray(data.items) ? data.items : []
88
+ return {
89
+ items: rawItems.map((row) => {
90
+ const createdAtRaw = row.created_at ?? row.createdAt ?? null
91
+ const createdAt = createdAtRaw ? new Date(String(createdAtRaw)).toISOString() : null
92
+ return {
93
+ id: row.id,
94
+ code: row.code,
95
+ name: row.name,
96
+ description: row.description ?? null,
97
+ schema: row.schema,
98
+ metadata: row.metadata ?? null,
99
+ isActive: !!(row.is_active ?? row.isActive),
100
+ organizationId: row.organization_id ?? row.organizationId ?? null,
101
+ tenantId: row.tenant_id ?? row.tenantId ?? null,
102
+ createdAt,
103
+ }
104
+ }),
105
+ total: typeof data.total === 'number' ? data.total : 0,
106
+ limit,
107
+ offset,
108
+ }
109
+ },
110
+ }) as unknown as CatalogAiToolDefinition
111
+
112
+ const listUnitConversionsInput = z
113
+ .object({
114
+ productId: z.string().uuid().optional().describe('Restrict to unit conversions for this product.'),
115
+ limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),
116
+ offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),
117
+ })
118
+ .passthrough()
119
+
120
+ type ListUnitConversionsInput = z.infer<typeof listUnitConversionsInput>
121
+
122
+ type ListUnitConversionsApiItem = {
123
+ id?: string
124
+ product_id?: string | null
125
+ productId?: string | null
126
+ unit_code?: string | null
127
+ unitCode?: string | null
128
+ to_base_factor?: string | number | null
129
+ toBaseFactor?: string | number | null
130
+ sort_order?: number | null
131
+ sortOrder?: number | null
132
+ is_active?: boolean | null
133
+ isActive?: boolean | null
134
+ metadata?: unknown
135
+ organization_id?: string | null
136
+ organizationId?: string | null
137
+ tenant_id?: string | null
138
+ tenantId?: string | null
139
+ created_at?: string | null
140
+ createdAt?: string | null
141
+ }
142
+
143
+ type ListUnitConversionsApiResponse = {
144
+ items?: ListUnitConversionsApiItem[]
145
+ total?: number
146
+ }
147
+
148
+ type ListUnitConversionsOutput = {
149
+ items: Array<Record<string, unknown>>
150
+ total: number
151
+ limit: number
152
+ offset: number
153
+ }
154
+
155
+ const listUnitConversionsTool = defineApiBackedAiTool<
156
+ ListUnitConversionsInput,
157
+ ListUnitConversionsApiResponse,
158
+ ListUnitConversionsOutput
159
+ >({
160
+ name: 'catalog.list_unit_conversions',
161
+ displayName: 'List unit conversions',
162
+ description:
163
+ 'List product unit conversions (alternate units with `toBaseFactor`) for the caller tenant + organization. Optionally narrow by product.',
164
+ inputSchema: listUnitConversionsInput,
165
+ requiredFeatures: ['catalog.products.view'],
166
+ toOperation: (input, ctx) => {
167
+ assertTenantScope(ctx as unknown as CatalogToolContext)
168
+ const limit = input.limit ?? 50
169
+ const offset = input.offset ?? 0
170
+ const page = Math.floor(offset / limit) + 1
171
+ const query: Record<string, string | number | boolean | null | undefined> = {
172
+ page,
173
+ pageSize: limit,
174
+ }
175
+ if (input.productId) query.productId = input.productId
176
+ const operation: AiApiOperationRequest = {
177
+ method: 'GET',
178
+ path: '/catalog/product-unit-conversions',
179
+ query,
180
+ }
181
+ return operation
182
+ },
183
+ mapResponse: (response, input) => {
184
+ const limit = input.limit ?? 50
185
+ const offset = input.offset ?? 0
186
+ const data = (response.data ?? {}) as ListUnitConversionsApiResponse
187
+ const rawItems: ListUnitConversionsApiItem[] = Array.isArray(data.items) ? data.items : []
188
+ return {
189
+ items: rawItems.map((row) => {
190
+ const createdAtRaw = row.created_at ?? row.createdAt ?? null
191
+ const createdAt = createdAtRaw ? new Date(String(createdAtRaw)).toISOString() : null
192
+ const toBaseFactor = row.to_base_factor ?? row.toBaseFactor ?? null
193
+ return {
194
+ id: row.id,
195
+ unitCode: row.unit_code ?? row.unitCode ?? null,
196
+ toBaseFactor: toBaseFactor === null ? null : String(toBaseFactor),
197
+ sortOrder: row.sort_order ?? row.sortOrder ?? 0,
198
+ isActive: !!(row.is_active ?? row.isActive),
199
+ productId: row.product_id ?? row.productId ?? null,
200
+ metadata: row.metadata ?? null,
201
+ organizationId: row.organization_id ?? row.organizationId ?? null,
202
+ tenantId: row.tenant_id ?? row.tenantId ?? null,
203
+ createdAt,
204
+ }
205
+ }),
206
+ total: typeof data.total === 'number' ? data.total : 0,
207
+ limit,
208
+ offset,
209
+ }
210
+ },
211
+ }) as unknown as CatalogAiToolDefinition
212
+
213
+ export const configurationAiTools: CatalogAiToolDefinition[] = [
214
+ listOptionSchemasTool,
215
+ listUnitConversionsTool,
216
+ ]
217
+
218
+ export default configurationAiTools
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `catalog.list_product_media` + `catalog.list_product_tags` (Phase 1 WS-C,
3
+ * Step 3.10).
4
+ *
5
+ * Media tool returns metadata only — bytes flow through the Step 3.7
6
+ * attachment bridge, not this enumeration. Tags tool mirrors the existing
7
+ * `/api/catalog/tags` GET surface (feature-gated the same way).
8
+ */
9
+ import type { EntityManager } from '@mikro-orm/postgresql'
10
+ import { z } from 'zod'
11
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
12
+ import { E } from '#generated/entities.ids.generated'
13
+ import { Attachment } from '@open-mercato/core/modules/attachments/data/entities'
14
+ import { CatalogProductTag, CatalogProductTagAssignment } from '../data/entities'
15
+ import { assertTenantScope, type CatalogAiToolDefinition, type CatalogToolContext } from './types'
16
+
17
+ function resolveEm(ctx: CatalogToolContext): EntityManager {
18
+ return ctx.container.resolve<EntityManager>('em')
19
+ }
20
+
21
+ function buildScope(ctx: CatalogToolContext, tenantId: string) {
22
+ return { tenantId, organizationId: ctx.organizationId }
23
+ }
24
+
25
+ const listProductMediaInput = z
26
+ .object({
27
+ productId: z.string().uuid().describe('Catalog product id (UUID).'),
28
+ limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),
29
+ offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),
30
+ })
31
+ .passthrough()
32
+
33
+ const listProductMediaTool: CatalogAiToolDefinition = {
34
+ name: 'catalog.list_product_media',
35
+ displayName: 'List product media',
36
+ description:
37
+ 'Enumerate media attachments (metadata only) associated with a catalog product. Use the attachment bridge (Step 3.7) to fetch bytes.',
38
+ inputSchema: listProductMediaInput,
39
+ requiredFeatures: ['catalog.products.view'],
40
+ tags: ['read', 'catalog'],
41
+ handler: async (rawInput, ctx) => {
42
+ const { tenantId } = assertTenantScope(ctx)
43
+ const input = listProductMediaInput.parse(rawInput)
44
+ const em = resolveEm(ctx)
45
+ const limit = input.limit ?? 50
46
+ const offset = input.offset ?? 0
47
+ const where: Record<string, unknown> = {
48
+ tenantId,
49
+ entityId: E.catalog.catalog_product,
50
+ recordId: input.productId,
51
+ }
52
+ if (ctx.organizationId) where.organizationId = ctx.organizationId
53
+ const [rows, total] = await Promise.all([
54
+ findWithDecryption<Attachment>(
55
+ em,
56
+ Attachment,
57
+ where as any,
58
+ { limit, offset, orderBy: { createdAt: 'asc' } as any } as any,
59
+ buildScope(ctx, tenantId),
60
+ ),
61
+ em.count(Attachment, where as any),
62
+ ])
63
+ const filtered = rows.filter((row) => (row.tenantId ?? null) === tenantId)
64
+ return {
65
+ items: filtered.map((row) => ({
66
+ id: row.id,
67
+ fileName: row.fileName,
68
+ mimeType: row.mimeType,
69
+ fileSize: row.fileSize,
70
+ url: row.url,
71
+ storageDriver: row.storageDriver,
72
+ partitionCode: row.partitionCode,
73
+ entityId: row.entityId,
74
+ recordId: row.recordId,
75
+ organizationId: row.organizationId ?? null,
76
+ tenantId: row.tenantId ?? null,
77
+ createdAt: row.createdAt ? new Date(row.createdAt).toISOString() : null,
78
+ })),
79
+ total,
80
+ limit,
81
+ offset,
82
+ }
83
+ },
84
+ }
85
+
86
+ const listProductTagsInput = z
87
+ .object({
88
+ productId: z.string().uuid().describe('Catalog product id (UUID).'),
89
+ })
90
+ .passthrough()
91
+
92
+ const listProductTagsTool: CatalogAiToolDefinition = {
93
+ name: 'catalog.list_product_tags',
94
+ displayName: 'List product tags',
95
+ description:
96
+ 'Enumerate tags assigned to a catalog product (label + slug). Returns { items, total }.',
97
+ inputSchema: listProductTagsInput,
98
+ requiredFeatures: ['catalog.products.view'],
99
+ tags: ['read', 'catalog'],
100
+ handler: async (rawInput, ctx) => {
101
+ const { tenantId } = assertTenantScope(ctx)
102
+ const input = listProductTagsInput.parse(rawInput)
103
+ const em = resolveEm(ctx)
104
+ const where: Record<string, unknown> = { tenantId, product: input.productId }
105
+ if (ctx.organizationId) where.organizationId = ctx.organizationId
106
+ const assignments = await findWithDecryption<CatalogProductTagAssignment>(
107
+ em,
108
+ CatalogProductTagAssignment,
109
+ where as any,
110
+ { populate: ['tag'] as any } as any,
111
+ buildScope(ctx, tenantId),
112
+ )
113
+ const filtered = assignments.filter((assignment) => assignment.tenantId === tenantId)
114
+ const items = filtered
115
+ .map((assignment) => {
116
+ const tag = (assignment as any).tag as CatalogProductTag | string | null
117
+ if (!tag || typeof tag === 'string') return null
118
+ return { id: tag.id, label: tag.label, slug: tag.slug }
119
+ })
120
+ .filter((value): value is { id: string; label: string; slug: string } => value !== null)
121
+ return { items, total: items.length }
122
+ },
123
+ }
124
+
125
+ export const mediaTagsAiTools: CatalogAiToolDefinition[] = [listProductMediaTool, listProductTagsTool]
126
+
127
+ export default mediaTagsAiTools