@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,487 @@
1
+ /**
2
+ * Shared helpers for catalog AI tool packs (Phase 1 WS-C, Steps 3.10 / 3.11 / 3.12).
3
+ *
4
+ * Step 3.10/3.11 centralized the price-kind enumeration query used by both the
5
+ * base tool (`catalog.list_price_kinds_base`) and the D18 spec-named tool
6
+ * (`catalog.list_price_kinds`).
7
+ *
8
+ * Step 3.12 lifts the **product-bundle builder** (and the merged
9
+ * attribute-schema resolver) here too, so the D18 AI-authoring tools
10
+ * (`draft_description_from_attributes`, `extract_attributes_from_description`,
11
+ * `draft_description_from_media`, `suggest_title_variants`,
12
+ * `suggest_price_adjustment`) can reuse them verbatim without either
13
+ * duplicating the logic or depending on an internal symbol in
14
+ * `merchandising-pack.ts`. Both packs (`merchandising-pack.ts` and
15
+ * `authoring-pack.ts`) consume these helpers; neither pack owns the bundle
16
+ * loader any more.
17
+ *
18
+ * Keeping the shared pieces query-shaped (not tool-shaped) means each tool is
19
+ * free to project its own output shape without leaking concerns between packs.
20
+ */
21
+ import type { EntityManager } from '@mikro-orm/postgresql'
22
+ import {
23
+ findOneWithDecryption,
24
+ findWithDecryption,
25
+ } from '@open-mercato/shared/lib/encryption/find'
26
+ import {
27
+ loadCustomFieldDefinitionIndex,
28
+ loadCustomFieldValues,
29
+ type CustomFieldDefinitionSummary,
30
+ } from '@open-mercato/shared/lib/crud/custom-fields'
31
+ import { E } from '#generated/entities.ids.generated'
32
+ import { Attachment } from '@open-mercato/core/modules/attachments/data/entities'
33
+ import {
34
+ CatalogPriceKind,
35
+ CatalogProduct,
36
+ CatalogProductCategoryAssignment,
37
+ CatalogProductPrice,
38
+ CatalogProductTag,
39
+ CatalogProductTagAssignment,
40
+ CatalogProductUnitConversion,
41
+ CatalogProductVariant,
42
+ } from '../data/entities'
43
+ import type { CatalogPricingService } from '../services/catalogPricingService'
44
+ import type { PriceRow, PricingContext } from '../lib/pricing'
45
+ import type { CatalogToolContext } from './types'
46
+
47
+ /* -------------------------------------------------------------------------- */
48
+ /* Price-kind enumeration shared core */
49
+ /* -------------------------------------------------------------------------- */
50
+
51
+ export type ListPriceKindsCoreInput = {
52
+ limit?: number
53
+ offset?: number
54
+ }
55
+
56
+ export type ListPriceKindsCoreRow = {
57
+ id: string
58
+ code: string
59
+ title: string
60
+ displayMode: string
61
+ currencyCode: string | null
62
+ isPromotion: boolean
63
+ isActive: boolean
64
+ organizationId: string | null
65
+ tenantId: string | null
66
+ createdAt: string | null
67
+ updatedAt: string | null
68
+ }
69
+
70
+ export type ListPriceKindsCoreResult = {
71
+ items: ListPriceKindsCoreRow[]
72
+ total: number
73
+ limit: number
74
+ offset: number
75
+ }
76
+
77
+ export function resolveEm(ctx: CatalogToolContext): EntityManager {
78
+ return ctx.container.resolve<EntityManager>('em')
79
+ }
80
+
81
+ export function buildScope(ctx: CatalogToolContext, tenantId: string) {
82
+ return { tenantId, organizationId: ctx.organizationId }
83
+ }
84
+
85
+ /**
86
+ * Shared tenant-scoped enumeration of `CatalogPriceKind` rows.
87
+ *
88
+ * Uses `findWithDecryption` + post-filter. Price kinds are tenant-owned and
89
+ * can be either organization-scoped (match `ctx.organizationId`) or
90
+ * null-scoped (shared across the tenant); the `$or` below mirrors the
91
+ * filter the base tool used pre-refactor so behavior stays identical.
92
+ */
93
+ export async function listPriceKindsCore(
94
+ ctx: CatalogToolContext,
95
+ input: ListPriceKindsCoreInput,
96
+ tenantId: string,
97
+ ): Promise<ListPriceKindsCoreResult> {
98
+ const em = resolveEm(ctx)
99
+ const limit = input.limit ?? 50
100
+ const offset = input.offset ?? 0
101
+ const where: Record<string, unknown> = { tenantId, deletedAt: null }
102
+ if (ctx.organizationId) {
103
+ where.$or = [{ organizationId: ctx.organizationId }, { organizationId: null }]
104
+ }
105
+ const [rows, total] = await Promise.all([
106
+ findWithDecryption<CatalogPriceKind>(
107
+ em,
108
+ CatalogPriceKind,
109
+ where as any,
110
+ { limit, offset, orderBy: { code: 'asc' } as any } as any,
111
+ buildScope(ctx, tenantId),
112
+ ),
113
+ em.count(CatalogPriceKind, where as any),
114
+ ])
115
+ const filtered = rows.filter((row) => row.tenantId === tenantId)
116
+ return {
117
+ items: filtered.map((row) => ({
118
+ id: row.id,
119
+ code: row.code,
120
+ title: row.title,
121
+ displayMode: row.displayMode,
122
+ currencyCode: row.currencyCode ?? null,
123
+ isPromotion: !!row.isPromotion,
124
+ isActive: !!row.isActive,
125
+ organizationId: row.organizationId ?? null,
126
+ tenantId: row.tenantId ?? null,
127
+ createdAt: row.createdAt ? new Date(row.createdAt).toISOString() : null,
128
+ updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null,
129
+ })),
130
+ total,
131
+ limit,
132
+ offset,
133
+ }
134
+ }
135
+
136
+ /* -------------------------------------------------------------------------- */
137
+ /* Product summary + bundle builder */
138
+ /* -------------------------------------------------------------------------- */
139
+
140
+ export type ProductSummary = {
141
+ id: string
142
+ title: string
143
+ subtitle: string | null
144
+ sku: string | null
145
+ handle: string | null
146
+ productType: string
147
+ statusEntryId: string | null
148
+ primaryCurrencyCode: string | null
149
+ defaultMediaId: string | null
150
+ defaultMediaUrl: string | null
151
+ /**
152
+ * Alias of `defaultMediaUrl`. Surfaced under the same key the
153
+ * `open-mercato:product` record card consumes so the model can pass it
154
+ * straight through without renaming. Null when the product has no
155
+ * default media.
156
+ */
157
+ imageUrl: string | null
158
+ isActive: boolean
159
+ isConfigurable: boolean
160
+ organizationId: string | null
161
+ tenantId: string | null
162
+ createdAt: string | null
163
+ updatedAt: string | null
164
+ description: string | null
165
+ }
166
+
167
+ export function toProductSummary(row: CatalogProduct): ProductSummary {
168
+ return {
169
+ id: row.id,
170
+ title: row.title,
171
+ subtitle: row.subtitle ?? null,
172
+ sku: row.sku ?? null,
173
+ handle: row.handle ?? null,
174
+ productType: row.productType,
175
+ statusEntryId: row.statusEntryId ?? null,
176
+ primaryCurrencyCode: row.primaryCurrencyCode ?? null,
177
+ defaultMediaId: row.defaultMediaId ?? null,
178
+ defaultMediaUrl: row.defaultMediaUrl ?? null,
179
+ imageUrl: row.defaultMediaUrl ?? null,
180
+ isActive: !!row.isActive,
181
+ isConfigurable: !!row.isConfigurable,
182
+ organizationId: row.organizationId ?? null,
183
+ tenantId: row.tenantId ?? null,
184
+ createdAt: row.createdAt ? new Date(row.createdAt).toISOString() : null,
185
+ updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null,
186
+ // `description` is a product field used by D18 authoring tools to seed
187
+ // extract-attributes-from-description; falls back to null when absent.
188
+ description: (row as any).description ?? null,
189
+ }
190
+ }
191
+
192
+ export function toPriceNumeric(value: string | null | undefined): number | null {
193
+ if (value === null || value === undefined) return null
194
+ const parsed = Number(value)
195
+ return Number.isFinite(parsed) ? parsed : null
196
+ }
197
+
198
+ export type AttributeSchemaField = {
199
+ key: string
200
+ label: string | null
201
+ type: string | null
202
+ required: boolean
203
+ options: unknown | null
204
+ scope: 'module' | 'category' | 'product'
205
+ }
206
+
207
+ export type AttributeSchemaResult = {
208
+ fields: AttributeSchemaField[]
209
+ resolvedFor: { productId?: string; categoryId?: string }
210
+ }
211
+
212
+ function summarizeDefinitionAsField(
213
+ summary: CustomFieldDefinitionSummary,
214
+ scope: AttributeSchemaField['scope'],
215
+ ): AttributeSchemaField {
216
+ return {
217
+ key: summary.key,
218
+ label: summary.label ?? null,
219
+ type: summary.kind ?? null,
220
+ required: false,
221
+ options: null,
222
+ scope,
223
+ }
224
+ }
225
+
226
+ export async function resolveAttributeSchema(
227
+ ctx: CatalogToolContext,
228
+ tenantId: string,
229
+ productId?: string,
230
+ categoryId?: string,
231
+ ): Promise<AttributeSchemaResult> {
232
+ const em = resolveEm(ctx)
233
+ const organizationIds = ctx.organizationId ? [ctx.organizationId] : []
234
+ const moduleDefs = await loadCustomFieldDefinitionIndex({
235
+ em,
236
+ entityIds: [E.catalog.catalog_product, E.catalog.catalog_product_category],
237
+ tenantId,
238
+ organizationIds,
239
+ })
240
+ const fields: AttributeSchemaField[] = []
241
+ moduleDefs.forEach((entries) => {
242
+ const pick = entries[0]
243
+ if (!pick) return
244
+ const scope: AttributeSchemaField['scope'] = pick.organizationId ? 'product' : 'module'
245
+ fields.push(summarizeDefinitionAsField(pick, scope))
246
+ })
247
+ return {
248
+ fields,
249
+ resolvedFor: {
250
+ ...(productId ? { productId } : {}),
251
+ ...(categoryId ? { categoryId } : {}),
252
+ },
253
+ }
254
+ }
255
+
256
+ export type ProductBundleMediaEntry = {
257
+ mediaId: string
258
+ attachmentId: string
259
+ fileName: string
260
+ mediaType: string | null
261
+ size: number | null
262
+ altText: string | null
263
+ sortOrder: number
264
+ }
265
+
266
+ export type ProductBundle = {
267
+ found: true
268
+ id: string
269
+ product: ProductSummary
270
+ categories: Array<{ id: string; name: string | null; slug: string | null; path: string | null }>
271
+ tags: Array<{ id: string; label: string; slug: string }>
272
+ variants: Array<Record<string, unknown>>
273
+ prices: {
274
+ all: Array<Record<string, unknown>>
275
+ best: Record<string, unknown> | null
276
+ }
277
+ media: ProductBundleMediaEntry[]
278
+ customFields: Record<string, unknown>
279
+ attributeSchema: AttributeSchemaResult
280
+ translations: null
281
+ }
282
+
283
+ export type ProductBundleResult = ProductBundle | { found: false; productId: string }
284
+
285
+ function resolvePricingService(ctx: CatalogToolContext): CatalogPricingService | null {
286
+ try {
287
+ return ctx.container.resolve<CatalogPricingService>('catalogPricingService')
288
+ } catch {
289
+ return null
290
+ }
291
+ }
292
+
293
+ export async function buildProductBundle(
294
+ em: EntityManager,
295
+ ctx: CatalogToolContext,
296
+ tenantId: string,
297
+ productId: string,
298
+ ): Promise<ProductBundleResult> {
299
+ const where: Record<string, unknown> = {
300
+ id: productId,
301
+ tenantId,
302
+ deletedAt: null,
303
+ }
304
+ if (ctx.organizationId) where.organizationId = ctx.organizationId
305
+ const product = await findOneWithDecryption<CatalogProduct>(
306
+ em,
307
+ CatalogProduct,
308
+ where as any,
309
+ undefined,
310
+ buildScope(ctx, tenantId),
311
+ )
312
+ if (!product || product.tenantId !== tenantId) {
313
+ return { found: false as const, productId }
314
+ }
315
+ const scope = buildScope(ctx, tenantId)
316
+ const [
317
+ categoryAssignments,
318
+ tagAssignments,
319
+ variants,
320
+ prices,
321
+ mediaAttachments,
322
+ unitConversions,
323
+ customFieldValues,
324
+ attributeSchema,
325
+ ] = await Promise.all([
326
+ findWithDecryption<CatalogProductCategoryAssignment>(
327
+ em,
328
+ CatalogProductCategoryAssignment,
329
+ { tenantId, product: product.id } as any,
330
+ { limit: 100, populate: ['category'] as any } as any,
331
+ scope,
332
+ ),
333
+ findWithDecryption<CatalogProductTagAssignment>(
334
+ em,
335
+ CatalogProductTagAssignment,
336
+ { tenantId, product: product.id } as any,
337
+ { limit: 100, populate: ['tag'] as any } as any,
338
+ scope,
339
+ ),
340
+ findWithDecryption<CatalogProductVariant>(
341
+ em,
342
+ CatalogProductVariant,
343
+ { tenantId, product: product.id, deletedAt: null } as any,
344
+ { limit: 100, orderBy: { createdAt: 'asc' } as any } as any,
345
+ scope,
346
+ ),
347
+ findWithDecryption<CatalogProductPrice>(
348
+ em,
349
+ CatalogProductPrice,
350
+ { tenantId, product: product.id } as any,
351
+ { limit: 100, orderBy: { createdAt: 'asc' } as any } as any,
352
+ scope,
353
+ ),
354
+ findWithDecryption<Attachment>(
355
+ em,
356
+ Attachment,
357
+ { tenantId, entityId: E.catalog.catalog_product, recordId: product.id } as any,
358
+ { limit: 100, orderBy: { createdAt: 'asc' } as any } as any,
359
+ scope,
360
+ ),
361
+ findWithDecryption<CatalogProductUnitConversion>(
362
+ em,
363
+ CatalogProductUnitConversion,
364
+ { tenantId, product: product.id, deletedAt: null } as any,
365
+ { limit: 100, orderBy: { sortOrder: 'asc', createdAt: 'asc' } as any } as any,
366
+ scope,
367
+ ),
368
+ loadCustomFieldValues({
369
+ em,
370
+ entityId: E.catalog.catalog_product,
371
+ recordIds: [product.id],
372
+ tenantIdByRecord: { [product.id]: product.tenantId ?? null },
373
+ organizationIdByRecord: { [product.id]: product.organizationId ?? null },
374
+ tenantFallbacks: [product.tenantId ?? tenantId].filter((value): value is string => !!value),
375
+ }),
376
+ resolveAttributeSchema(ctx, tenantId, product.id, undefined),
377
+ ])
378
+
379
+ const categories = categoryAssignments
380
+ .map((assignment) => {
381
+ const category = (assignment as any).category
382
+ if (!category || typeof category === 'string') {
383
+ const fallbackId = typeof category === 'string' ? category : null
384
+ return fallbackId ? { id: fallbackId, name: null, slug: null, path: null } : null
385
+ }
386
+ return {
387
+ id: category.id,
388
+ name: category.name ?? null,
389
+ slug: category.slug ?? null,
390
+ path: category.treePath ?? null,
391
+ }
392
+ })
393
+ .filter((value): value is { id: string; name: string | null; slug: string | null; path: string | null } => value !== null)
394
+
395
+ const tags = tagAssignments
396
+ .map((assignment) => {
397
+ const tag = (assignment as any).tag as CatalogProductTag | string | null
398
+ if (!tag || typeof tag === 'string') return null
399
+ return { id: tag.id, label: tag.label, slug: tag.slug }
400
+ })
401
+ .filter((value): value is { id: string; label: string; slug: string } => value !== null)
402
+
403
+ const priceRows = prices.map((row) => ({
404
+ id: row.id,
405
+ priceKindId: (row as any).priceKind && typeof (row as any).priceKind === 'object'
406
+ ? (row as any).priceKind.id
407
+ : (row as any).priceKind ?? null,
408
+ currencyCode: row.currencyCode,
409
+ kind: row.kind,
410
+ minQuantity: row.minQuantity,
411
+ maxQuantity: row.maxQuantity ?? null,
412
+ unitPriceNet: row.unitPriceNet ?? null,
413
+ unitPriceGross: row.unitPriceGross ?? null,
414
+ taxRate: row.taxRate ?? null,
415
+ taxAmount: row.taxAmount ?? null,
416
+ channelId: row.channelId ?? null,
417
+ offerId: (row as any).offer && typeof (row as any).offer === 'object'
418
+ ? (row as any).offer.id
419
+ : (row as any).offer ?? null,
420
+ variantId: (row as any).variant && typeof (row as any).variant === 'object'
421
+ ? (row as any).variant.id
422
+ : (row as any).variant ?? null,
423
+ startsAt: row.startsAt ? new Date(row.startsAt).toISOString() : null,
424
+ endsAt: row.endsAt ? new Date(row.endsAt).toISOString() : null,
425
+ }))
426
+
427
+ let bestPrice: Record<string, unknown> | null = null
428
+ const pricingService = resolvePricingService(ctx)
429
+ if (pricingService && prices.length > 0) {
430
+ const pricingContext: PricingContext = {
431
+ quantity: 1,
432
+ date: new Date(),
433
+ }
434
+ try {
435
+ const resolved = await pricingService.resolvePrice(prices as unknown as PriceRow[], pricingContext)
436
+ if (resolved) {
437
+ bestPrice = {
438
+ id: (resolved as any).id,
439
+ currencyCode: (resolved as any).currencyCode,
440
+ kind: (resolved as any).kind,
441
+ unitPriceNet: (resolved as any).unitPriceNet ?? null,
442
+ unitPriceGross: (resolved as any).unitPriceGross ?? null,
443
+ }
444
+ }
445
+ } catch (error) {
446
+ console.warn('[catalog.get_product_bundle] resolvePrice failed, omitting best price', error)
447
+ }
448
+ }
449
+
450
+ return {
451
+ found: true,
452
+ id: product.id,
453
+ product: toProductSummary(product),
454
+ categories,
455
+ tags,
456
+ variants: variants.map((variant) => ({
457
+ id: variant.id,
458
+ name: variant.name ?? null,
459
+ sku: variant.sku ?? null,
460
+ barcode: variant.barcode ?? null,
461
+ optionValues: variant.optionValues ?? null,
462
+ defaultMediaId: variant.defaultMediaId ?? null,
463
+ defaultMediaUrl: variant.defaultMediaUrl ?? null,
464
+ isDefault: !!variant.isDefault,
465
+ isActive: !!variant.isActive,
466
+ })),
467
+ prices: {
468
+ all: priceRows,
469
+ best: bestPrice,
470
+ },
471
+ media: mediaAttachments.map((attachment) => ({
472
+ mediaId: attachment.id,
473
+ attachmentId: attachment.id,
474
+ fileName: attachment.fileName,
475
+ mediaType: attachment.mimeType,
476
+ size: attachment.fileSize,
477
+ altText: null,
478
+ sortOrder: 0,
479
+ })),
480
+ customFields: customFieldValues[product.id] ?? {},
481
+ attributeSchema,
482
+ // No translation resolver exists for catalog (no `translations.ts` at
483
+ // module root yet); returning null is an explicit null-surface contract
484
+ // and a hint for Step 5+ to add the translations resolver.
485
+ translations: null,
486
+ }
487
+ }