@open-mercato/core 0.6.4-develop.4236.1.9fa6806b34 → 0.6.4-develop.4254.1.7a123d970c

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 (118) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/authFixtures.js +70 -1
  3. package/dist/helpers/integration/authFixtures.js.map +2 -2
  4. package/dist/helpers/integration/dbFixtures.js +98 -0
  5. package/dist/helpers/integration/dbFixtures.js.map +7 -0
  6. package/dist/modules/business_rules/api/execute/route.js +2 -1
  7. package/dist/modules/business_rules/api/execute/route.js.map +2 -2
  8. package/dist/modules/business_rules/api/rules/route.js +10 -0
  9. package/dist/modules/business_rules/api/rules/route.js.map +2 -2
  10. package/dist/modules/business_rules/backend/logs/[id]/page.js +24 -5
  11. package/dist/modules/business_rules/backend/logs/[id]/page.js.map +2 -2
  12. package/dist/modules/business_rules/cli.js +6 -0
  13. package/dist/modules/business_rules/cli.js.map +2 -2
  14. package/dist/modules/business_rules/lib/rule-engine.js +116 -9
  15. package/dist/modules/business_rules/lib/rule-engine.js.map +2 -2
  16. package/dist/modules/business_rules/subscribers/crud-rule-trigger.js +3 -2
  17. package/dist/modules/business_rules/subscribers/crud-rule-trigger.js.map +2 -2
  18. package/dist/modules/catalog/api/offers/route.js +15 -5
  19. package/dist/modules/catalog/api/offers/route.js.map +2 -2
  20. package/dist/modules/catalog/api/products/route.js +21 -4
  21. package/dist/modules/catalog/api/products/route.js.map +2 -2
  22. package/dist/modules/catalog/lib/pricing.js +6 -0
  23. package/dist/modules/catalog/lib/pricing.js.map +2 -2
  24. package/dist/modules/catalog/services/catalogPricingService.js +5 -1
  25. package/dist/modules/catalog/services/catalogPricingService.js.map +2 -2
  26. package/dist/modules/currencies/backend/currencies/[id]/page.js +19 -2
  27. package/dist/modules/currencies/backend/currencies/[id]/page.js.map +2 -2
  28. package/dist/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.js +27 -7
  29. package/dist/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.js.map +2 -2
  30. package/dist/modules/customer_accounts/backend/customer_accounts/users/[id]/page.js +27 -7
  31. package/dist/modules/customer_accounts/backend/customer_accounts/users/[id]/page.js.map +2 -2
  32. package/dist/modules/customers/api/activities/route.js +15 -2
  33. package/dist/modules/customers/api/activities/route.js.map +2 -2
  34. package/dist/modules/customers/api/comments/route.js +15 -3
  35. package/dist/modules/customers/api/comments/route.js.map +2 -2
  36. package/dist/modules/customers/api/companies/[id]/people/route.js +2 -4
  37. package/dist/modules/customers/api/companies/[id]/people/route.js.map +2 -2
  38. package/dist/modules/customers/api/companies/[id]/route.js +2 -4
  39. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  40. package/dist/modules/customers/api/deals/[id]/companies/route.js +2 -4
  41. package/dist/modules/customers/api/deals/[id]/companies/route.js.map +2 -2
  42. package/dist/modules/customers/api/deals/[id]/people/route.js +2 -4
  43. package/dist/modules/customers/api/deals/[id]/people/route.js.map +2 -2
  44. package/dist/modules/customers/api/deals/[id]/route.js +2 -9
  45. package/dist/modules/customers/api/deals/[id]/route.js.map +2 -2
  46. package/dist/modules/customers/api/deals/[id]/stats/route.js +2 -9
  47. package/dist/modules/customers/api/deals/[id]/stats/route.js.map +2 -2
  48. package/dist/modules/customers/api/entity-roles-factory.js +2 -8
  49. package/dist/modules/customers/api/entity-roles-factory.js.map +2 -2
  50. package/dist/modules/customers/api/people/[id]/companies/context.js +2 -4
  51. package/dist/modules/customers/api/people/[id]/companies/context.js.map +2 -2
  52. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js +2 -4
  53. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js.map +2 -2
  54. package/dist/modules/customers/api/people/[id]/route.js +2 -4
  55. package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
  56. package/dist/modules/customers/backend/customers/people/[id]/page.js +29 -8
  57. package/dist/modules/customers/backend/customers/people/[id]/page.js.map +2 -2
  58. package/dist/modules/directory/utils/organizationScopeGuard.js +22 -0
  59. package/dist/modules/directory/utils/organizationScopeGuard.js.map +7 -0
  60. package/dist/modules/progress/acl.js +8 -4
  61. package/dist/modules/progress/acl.js.map +2 -2
  62. package/dist/modules/workflows/backend/events/[id]/page.js +24 -6
  63. package/dist/modules/workflows/backend/events/[id]/page.js.map +2 -2
  64. package/dist/modules/workflows/backend/instances/[id]/page.js +27 -5
  65. package/dist/modules/workflows/backend/instances/[id]/page.js.map +2 -2
  66. package/dist/modules/workflows/backend/tasks/[id]/page.js +25 -6
  67. package/dist/modules/workflows/backend/tasks/[id]/page.js.map +2 -2
  68. package/dist/modules/workflows/cli.js +8 -0
  69. package/dist/modules/workflows/cli.js.map +2 -2
  70. package/dist/modules/workflows/lib/seeds.js +8 -4
  71. package/dist/modules/workflows/lib/seeds.js.map +2 -2
  72. package/dist/modules/workflows/setup.js +3 -1
  73. package/dist/modules/workflows/setup.js.map +2 -2
  74. package/package.json +7 -7
  75. package/src/helpers/integration/authFixtures.ts +98 -0
  76. package/src/helpers/integration/dbFixtures.ts +144 -0
  77. package/src/modules/business_rules/api/execute/route.ts +2 -1
  78. package/src/modules/business_rules/api/rules/route.ts +10 -0
  79. package/src/modules/business_rules/backend/logs/[id]/page.tsx +32 -7
  80. package/src/modules/business_rules/cli.ts +6 -0
  81. package/src/modules/business_rules/lib/rule-engine.ts +163 -9
  82. package/src/modules/business_rules/subscribers/crud-rule-trigger.ts +3 -2
  83. package/src/modules/catalog/api/offers/route.ts +20 -5
  84. package/src/modules/catalog/api/products/route.ts +23 -4
  85. package/src/modules/catalog/lib/pricing.ts +9 -0
  86. package/src/modules/catalog/services/catalogPricingService.ts +6 -0
  87. package/src/modules/currencies/backend/currencies/[id]/page.tsx +21 -2
  88. package/src/modules/currencies/i18n/de.json +1 -0
  89. package/src/modules/currencies/i18n/en.json +1 -0
  90. package/src/modules/currencies/i18n/es.json +1 -0
  91. package/src/modules/currencies/i18n/pl.json +1 -0
  92. package/src/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.tsx +34 -11
  93. package/src/modules/customer_accounts/backend/customer_accounts/users/[id]/page.tsx +34 -11
  94. package/src/modules/customers/api/activities/route.ts +16 -5
  95. package/src/modules/customers/api/comments/route.ts +15 -5
  96. package/src/modules/customers/api/companies/[id]/people/route.ts +2 -4
  97. package/src/modules/customers/api/companies/[id]/route.ts +2 -5
  98. package/src/modules/customers/api/deals/[id]/companies/route.ts +2 -4
  99. package/src/modules/customers/api/deals/[id]/people/route.ts +2 -4
  100. package/src/modules/customers/api/deals/[id]/route.ts +2 -9
  101. package/src/modules/customers/api/deals/[id]/stats/route.ts +2 -9
  102. package/src/modules/customers/api/entity-roles-factory.ts +2 -12
  103. package/src/modules/customers/api/people/[id]/companies/context.ts +2 -5
  104. package/src/modules/customers/api/people/[id]/companies/enriched/route.ts +2 -5
  105. package/src/modules/customers/api/people/[id]/route.ts +2 -5
  106. package/src/modules/customers/backend/customers/people/[id]/page.tsx +35 -11
  107. package/src/modules/directory/utils/organizationScopeGuard.ts +39 -0
  108. package/src/modules/progress/acl.ts +4 -0
  109. package/src/modules/workflows/backend/events/[id]/page.tsx +32 -10
  110. package/src/modules/workflows/backend/instances/[id]/page.tsx +33 -9
  111. package/src/modules/workflows/backend/tasks/[id]/page.tsx +33 -10
  112. package/src/modules/workflows/cli.ts +8 -0
  113. package/src/modules/workflows/i18n/de.json +1 -0
  114. package/src/modules/workflows/i18n/en.json +1 -0
  115. package/src/modules/workflows/i18n/es.json +1 -0
  116. package/src/modules/workflows/i18n/pl.json +1 -0
  117. package/src/modules/workflows/lib/seeds.ts +13 -3
  118. package/src/modules/workflows/setup.ts +3 -1
@@ -10,6 +10,7 @@ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
10
10
  import { Button } from '@open-mercato/ui/primitives/button'
11
11
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
12
12
  import { JsonDisplay } from '@open-mercato/ui/backend/JsonDisplay'
13
+ import { RecordNotFoundState, ErrorMessage } from '@open-mercato/ui/backend/detail'
13
14
 
14
15
  type RuleExecutionLog = {
15
16
  id: string
@@ -54,7 +55,13 @@ export default function ExecutionLogDetailPage() {
54
55
  queryFn: async () => {
55
56
  const response = await apiFetch(`/api/business_rules/logs/${logId}`)
56
57
  if (!response.ok) {
57
- throw new Error(t('business_rules.logs.errors.fetchFailed'))
58
+ const httpErr = new Error(
59
+ response.status === 404
60
+ ? t('business_rules.logs.errors.notFound', 'Execution log not found.')
61
+ : t('business_rules.logs.errors.fetchFailed')
62
+ ) as Error & { status: number }
63
+ httpErr.status = response.status
64
+ throw httpErr
58
65
  }
59
66
  const result = await response.json()
60
67
  return result as RuleExecutionLog
@@ -75,16 +82,34 @@ export default function ExecutionLogDetailPage() {
75
82
  )
76
83
  }
77
84
 
85
+ const isNotFound = !isLoading && (error as (Error & { status?: number }) | null)?.status === 404
86
+
87
+ if (isNotFound) {
88
+ return (
89
+ <Page>
90
+ <PageBody>
91
+ <RecordNotFoundState
92
+ label={t('business_rules.logs.errors.notFound', 'Execution log not found.')}
93
+ backHref="/backend/logs"
94
+ backLabel={t('business_rules.logs.backToList', 'Back to logs')}
95
+ />
96
+ </PageBody>
97
+ </Page>
98
+ )
99
+ }
100
+
78
101
  if (error || !log) {
79
102
  return (
80
103
  <Page>
81
104
  <PageBody>
82
- <div className="flex h-[50vh] flex-col items-center justify-center gap-2 text-muted-foreground">
83
- <p>{error ? t('business_rules.logs.errors.loadFailed') : t('business_rules.logs.errors.notFound')}</p>
84
- <Button asChild variant="outline">
85
- <Link href="/backend/logs">{t('business_rules.logs.backToList')}</Link>
86
- </Button>
87
- </div>
105
+ <ErrorMessage
106
+ label={(error as Error | null)?.message ?? t('business_rules.logs.errors.loadFailed')}
107
+ action={
108
+ <Button asChild variant="outline" size="sm">
109
+ <Link href="/backend/logs">{t('business_rules.logs.backToList', 'Back to logs')}</Link>
110
+ </Button>
111
+ }
112
+ />
88
113
  </PageBody>
89
114
  </Page>
90
115
  )
@@ -2,6 +2,10 @@ import type { ModuleCli } from '@open-mercato/shared/modules/registry'
2
2
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
4
  import { BusinessRule } from './data/entities'
5
+ import {
6
+ invalidateBusinessRuleDiscoveryCache,
7
+ resolveBusinessRuleDiscoveryCache,
8
+ } from './lib/rule-engine'
5
9
  import * as fs from 'fs'
6
10
  import * as path from 'path'
7
11
 
@@ -39,6 +43,7 @@ const seedGuardRules: ModuleCli = {
39
43
  try {
40
44
  const { resolve } = await createRequestContainer()
41
45
  const em = resolve<EntityManager>('em')
46
+ const cache = resolveBusinessRuleDiscoveryCache(resolve)
42
47
 
43
48
  // Read guard rules from workflows examples
44
49
  const rulesPath = path.join(__dirname, '../workflows/examples', 'guard-rules-example.json')
@@ -70,6 +75,7 @@ const seedGuardRules: ModuleCli = {
70
75
  })
71
76
 
72
77
  await em.persist(rule).flush()
78
+ await invalidateBusinessRuleDiscoveryCache(cache, tenantId, organizationId)
73
79
  console.log(` ✓ Seeded guard rule: ${rule.ruleName}`)
74
80
  seededCount++
75
81
  }
@@ -1,4 +1,5 @@
1
1
  import type { EntityManager } from '@mikro-orm/core'
2
+ import { runWithCacheTenant, type CacheStrategy } from '@open-mercato/cache'
2
3
  import type { EventBus } from '@open-mercato/events'
3
4
  import { BusinessRule, RuleExecutionLog, type RuleType } from '../data/entities'
4
5
  import * as ruleEvaluator from './rule-evaluator'
@@ -22,6 +23,7 @@ const EXECUTION_RESULT_FAILURE = 'FAILURE'
22
23
  const MAX_RULES_PER_EXECUTION = 100
23
24
  const MAX_SINGLE_RULE_TIMEOUT_MS = 30000 // 30 seconds
24
25
  const MAX_TOTAL_EXECUTION_TIMEOUT_MS = 60000 // 60 seconds
26
+ const RULE_DISCOVERY_CACHE_TTL = 5 * 60 * 1000
25
27
 
26
28
  /**
27
29
  * Rule execution context
@@ -86,8 +88,138 @@ export interface RuleDiscoveryOptions {
86
88
  ruleType?: RuleType
87
89
  }
88
90
 
91
+ type CachedRuleDiscovery = {
92
+ ruleIds: string[]
93
+ }
94
+
95
+ export type RuleDiscoveryCache = Pick<CacheStrategy, 'get' | 'set' | 'deleteByTags'>
96
+
97
+ export type RuleDiscoveryCacheOptions = {
98
+ cache?: RuleDiscoveryCache | null
99
+ }
100
+
101
+ function normalizeCachePart(value: string | null | undefined): string {
102
+ return encodeURIComponent(value?.trim() || '*')
103
+ }
104
+
105
+ function getRuleDiscoveryCacheKey(options: RuleDiscoveryOptions): string {
106
+ return [
107
+ normalizeCachePart(options.tenantId),
108
+ normalizeCachePart(options.organizationId),
109
+ normalizeCachePart(options.entityType),
110
+ normalizeCachePart(options.eventType),
111
+ normalizeCachePart(options.ruleType),
112
+ ].join(':')
113
+ }
114
+
115
+ function isCachedRuleDiscovery(value: unknown): value is CachedRuleDiscovery {
116
+ if (!value || typeof value !== 'object') return false
117
+ const ruleIds = (value as CachedRuleDiscovery).ruleIds
118
+ return Array.isArray(ruleIds) && ruleIds.every((entry) => typeof entry === 'string')
119
+ }
120
+
121
+ export function isRuleDiscoveryCache(value: unknown): value is RuleDiscoveryCache {
122
+ if (!value || typeof value !== 'object') return false
123
+ const candidate = value as Partial<RuleDiscoveryCache>
124
+ return (
125
+ typeof candidate.get === 'function'
126
+ && typeof candidate.set === 'function'
127
+ && typeof candidate.deleteByTags === 'function'
128
+ )
129
+ }
130
+
131
+ export function resolveBusinessRuleDiscoveryCache(resolve: (token: string) => unknown): RuleDiscoveryCache | null {
132
+ try {
133
+ const cache = resolve('cache')
134
+ return isRuleDiscoveryCache(cache) ? cache : null
135
+ } catch {
136
+ return null
137
+ }
138
+ }
139
+
140
+ function getRuleDiscoveryCacheTags(options: Pick<RuleDiscoveryOptions, 'organizationId'>): string[] {
141
+ return [
142
+ 'business_rules:discovery',
143
+ `business_rules:discovery:organization:${options.organizationId}`,
144
+ ]
145
+ }
146
+
147
+ async function getCachedRuleDiscovery(
148
+ cache: RuleDiscoveryCache | null | undefined,
149
+ options: RuleDiscoveryOptions,
150
+ ): Promise<CachedRuleDiscovery | null> {
151
+ if (!cache) return null
152
+
153
+ let value: unknown
154
+ try {
155
+ value = await runWithCacheTenant(options.tenantId, () =>
156
+ cache.get(getRuleDiscoveryCacheKey(options))
157
+ )
158
+ } catch (error) {
159
+ console.warn('[business_rules] Failed to read rule discovery cache:', error)
160
+ return null
161
+ }
162
+
163
+ return isCachedRuleDiscovery(value) ? value : null
164
+ }
165
+
166
+ async function cacheRuleDiscovery(
167
+ cache: RuleDiscoveryCache | null | undefined,
168
+ options: RuleDiscoveryOptions,
169
+ rules: BusinessRule[],
170
+ ): Promise<void> {
171
+ if (!cache) return
172
+
173
+ const ruleIds = rules
174
+ .map((rule) => rule.id)
175
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
176
+
177
+ if (ruleIds.length !== rules.length) return
178
+
179
+ try {
180
+ await runWithCacheTenant(options.tenantId, () =>
181
+ cache.set(
182
+ getRuleDiscoveryCacheKey(options),
183
+ { ruleIds },
184
+ {
185
+ ttl: RULE_DISCOVERY_CACHE_TTL,
186
+ tags: getRuleDiscoveryCacheTags(options),
187
+ },
188
+ )
189
+ )
190
+ } catch (error) {
191
+ console.warn('[business_rules] Failed to write rule discovery cache:', error)
192
+ }
193
+ }
194
+
195
+ export async function invalidateBusinessRuleDiscoveryCache(
196
+ cache: RuleDiscoveryCache | null | undefined,
197
+ tenantId?: string | null,
198
+ organizationId?: string | null,
199
+ ): Promise<void> {
200
+ if (!cache) return
201
+
202
+ const normalizedTenantId = tenantId?.trim()
203
+ const normalizedOrganizationId = organizationId?.trim()
204
+
205
+ if (!normalizedTenantId) {
206
+ return
207
+ }
208
+
209
+ const tags = normalizedOrganizationId
210
+ ? [`business_rules:discovery:organization:${normalizedOrganizationId}`]
211
+ : ['business_rules:discovery']
212
+
213
+ try {
214
+ await runWithCacheTenant(normalizedTenantId, () => cache.deleteByTags(tags))
215
+ } catch (error) {
216
+ console.warn('[business_rules] Failed to invalidate rule discovery cache:', error)
217
+ }
218
+ }
219
+
89
220
  export type RuleEngineExecutionOptions = {
90
221
  eventBus?: Pick<EventBus, 'emitEvent'> | null
222
+ cache?: RuleDiscoveryCache | null
91
223
  }
92
224
 
93
225
  type RuleExecutionFailedPayload = {
@@ -213,7 +345,7 @@ export async function executeRules(
213
345
  eventType: context.eventType,
214
346
  tenantId: context.tenantId,
215
347
  organizationId: context.organizationId,
216
- })
348
+ }, { cache: options.cache })
217
349
 
218
350
  // Check rule count limit
219
351
  if (rules.length > MAX_RULES_PER_EXECUTION) {
@@ -468,14 +600,14 @@ export async function executeSingleRule(
468
600
  */
469
601
  export async function findApplicableRules(
470
602
  em: EntityManager,
471
- options: RuleDiscoveryOptions
603
+ options: RuleDiscoveryOptions,
604
+ cacheOptions: RuleDiscoveryCacheOptions = {},
472
605
  ): Promise<BusinessRule[]> {
473
606
  // Validate input
474
607
  ruleDiscoveryOptionsSchema.parse(options)
475
608
 
476
609
  const { entityType, eventType, tenantId, organizationId, ruleType } = options
477
-
478
- const where: Partial<BusinessRule> = {
610
+ const baseWhere: Record<string, unknown> = {
479
611
  entityType,
480
612
  tenantId,
481
613
  organizationId,
@@ -484,16 +616,38 @@ export async function findApplicableRules(
484
616
  }
485
617
 
486
618
  if (eventType) {
487
- where.eventType = eventType
619
+ baseWhere.eventType = eventType
488
620
  }
489
621
 
490
622
  if (ruleType) {
491
- where.ruleType = ruleType
623
+ baseWhere.ruleType = ruleType
492
624
  }
493
625
 
494
- const rules = await em.find(BusinessRule, where, {
495
- orderBy: { priority: 'DESC', ruleId: 'ASC' },
496
- })
626
+ const cached = await getCachedRuleDiscovery(cacheOptions.cache, options)
627
+ let rules: BusinessRule[]
628
+
629
+ if (cached) {
630
+ if (cached.ruleIds.length === 0) {
631
+ rules = []
632
+ } else {
633
+ const cachedRules = await em.find(BusinessRule, {
634
+ ...baseWhere,
635
+ id: { $in: cached.ruleIds },
636
+ }, {
637
+ orderBy: { priority: 'DESC', ruleId: 'ASC' },
638
+ } as any)
639
+ const byId = new Map(cachedRules.map((rule) => [rule.id, rule]))
640
+ rules = cached.ruleIds
641
+ .map((id) => byId.get(id))
642
+ .filter((rule): rule is BusinessRule => Boolean(rule))
643
+ }
644
+ } else {
645
+ rules = await em.find(BusinessRule, baseWhere as Partial<BusinessRule>, {
646
+ orderBy: { priority: 'DESC', ruleId: 'ASC' },
647
+ })
648
+
649
+ await cacheRuleDiscovery(cacheOptions.cache, options, rules)
650
+ }
497
651
 
498
652
  // Filter by effective date range
499
653
  const now = new Date()
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { EntityManager } from '@mikro-orm/core'
14
- import { executeRules } from '../lib/rule-engine'
14
+ import { executeRules, resolveBusinessRuleDiscoveryCache } from '../lib/rule-engine'
15
15
 
16
16
  export const metadata = {
17
17
  event: '*',
@@ -64,6 +64,7 @@ export default async function handle(
64
64
  if (!tenantId || !organizationId) return
65
65
 
66
66
  const em = ctx.resolve<EntityManager>('em')
67
+ const cache = resolveBusinessRuleDiscoveryCache(ctx.resolve)
67
68
 
68
69
  try {
69
70
  await executeRules(em, {
@@ -72,7 +73,7 @@ export default async function handle(
72
73
  data,
73
74
  tenantId,
74
75
  organizationId,
75
- })
76
+ }, { cache })
76
77
  } catch (error) {
77
78
  console.error(`[business_rules] Rule execution failed for event ${eventName}:`, error)
78
79
  }
@@ -94,11 +94,25 @@ export async function decorateOffersWithDetails(
94
94
  .filter((value): value is string => !!value)
95
95
  if (!offerIds.length && !productIds.length) return
96
96
  const em = ctx.container.resolve('em') as EntityManager
97
+ const scopeTenantId = ctx.auth?.tenantId ?? null
98
+ if (!scopeTenantId) {
99
+ throw new CrudHttpError(403, '[internal] Missing tenant scope for offer decoration')
100
+ }
101
+ const scopeOrgIds =
102
+ Array.isArray(ctx.organizationIds) && ctx.organizationIds.length
103
+ ? Array.from(new Set(ctx.organizationIds))
104
+ : (ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null)
105
+ ? [(ctx.selectedOrganizationId ?? ctx.auth?.orgId) as string]
106
+ : []
107
+ const scopeWhere: Record<string, unknown> = { tenantId: scopeTenantId }
108
+ if (scopeOrgIds.length === 1) scopeWhere.organizationId = scopeOrgIds[0]
109
+ else if (scopeOrgIds.length > 1) scopeWhere.organizationId = { $in: scopeOrgIds }
110
+ const scope = { tenantId: scopeTenantId, organizationId: scopeOrgIds.length === 1 ? scopeOrgIds[0] : null }
97
111
  const [products, prices, defaultVariants] = await Promise.all([
98
112
  productIds.length
99
113
  ? em.find(
100
114
  CatalogProduct,
101
- { id: { $in: productIds } },
115
+ { id: { $in: productIds }, ...scopeWhere },
102
116
  {
103
117
  fields: ['id', 'title', 'description', 'defaultMediaId', 'defaultMediaUrl', 'sku'],
104
118
  },
@@ -108,15 +122,15 @@ export async function decorateOffersWithDetails(
108
122
  ? findWithDecryption(
109
123
  em,
110
124
  CatalogProductPrice,
111
- { offer: { $in: offerIds } },
125
+ { offer: { $in: offerIds }, ...scopeWhere },
112
126
  { populate: ['priceKind'] },
113
- { tenantId: ctx.auth?.tenantId ?? null, organizationId: ctx.auth?.orgId ?? null },
127
+ scope,
114
128
  )
115
129
  : [],
116
130
  productIds.length
117
131
  ? em.find(
118
132
  CatalogProductVariant,
119
- { product: { $in: productIds }, isDefault: true },
133
+ { product: { $in: productIds }, isDefault: true, ...scopeWhere },
120
134
  { fields: ['id', 'product'] },
121
135
  )
122
136
  : [],
@@ -227,6 +241,7 @@ export async function decorateOffersWithDetails(
227
241
  CatalogProductPrice,
228
242
  {
229
243
  offer: null,
244
+ ...scopeWhere,
230
245
  $and: [
231
246
  { $or: fallbackTargets },
232
247
  channelFilterValues.includes(null)
@@ -240,7 +255,7 @@ export async function decorateOffersWithDetails(
240
255
  ],
241
256
  },
242
257
  { populate: ['priceKind'] },
243
- { tenantId: ctx.auth?.tenantId ?? null, organizationId: ctx.auth?.orgId ?? null },
258
+ scope,
244
259
  )
245
260
  : []
246
261
  fallbackEntries.forEach((entry) => {
@@ -628,9 +628,13 @@ async function decorateProductsAfterList(
628
628
  "catalogPricingService",
629
629
  );
630
630
 
631
+ const pricingEntries: Array<{ rows: PriceRow[]; context: PricingContext } | null> = [];
631
632
  for (const item of items) {
632
633
  const id = typeof item.id === "string" ? item.id : null;
633
- if (!id) continue;
634
+ if (!id) {
635
+ pricingEntries.push(null);
636
+ continue;
637
+ }
634
638
  const offerEntries = offersByProduct.get(id) ?? [];
635
639
  item.offers = offerEntries;
636
640
  const channelIds = Array.from(
@@ -668,10 +672,25 @@ async function decorateProductsAfterList(
668
672
  pricingContext.channelId || channelIds.length !== 1
669
673
  ? pricingContext
670
674
  : { ...pricingContext, channelId: channelIds[0] };
671
- const best = await pricingService.resolvePrice(priceCandidates, {
672
- ...channelScopedContext,
673
- quantity: normalizedQuantityForPricing,
675
+ pricingEntries.push({
676
+ rows: priceCandidates,
677
+ context: { ...channelScopedContext, quantity: normalizedQuantityForPricing },
674
678
  });
679
+ }
680
+
681
+ const resolveInputs: Array<{ rows: PriceRow[]; context: PricingContext }> = [];
682
+ const resolveIndices: number[] = [];
683
+ for (let i = 0; i < pricingEntries.length; i++) {
684
+ if (pricingEntries[i] !== null) {
685
+ resolveInputs.push(pricingEntries[i]!);
686
+ resolveIndices.push(i);
687
+ }
688
+ }
689
+ const priceResults = await pricingService.resolvePriceMany(resolveInputs);
690
+
691
+ for (let i = 0; i < resolveIndices.length; i++) {
692
+ const item = items[resolveIndices[i]];
693
+ const best = priceResults[i];
675
694
  if (best) {
676
695
  item.pricing = {
677
696
  kind: resolvePriceKindCode(best),
@@ -180,3 +180,12 @@ export async function resolveCatalogPrice(
180
180
 
181
181
  return resolved ?? null
182
182
  }
183
+
184
+ export async function resolveCatalogPriceBatch(
185
+ entries: Array<{ rows: PriceRow[]; context: PricingContext }>,
186
+ options?: { eventBus?: EventBus | null }
187
+ ): Promise<Array<PriceRow | null>> {
188
+ return Promise.all(
189
+ entries.map(({ rows, context }) => resolveCatalogPrice(rows, context, options))
190
+ )
191
+ }
@@ -1,12 +1,14 @@
1
1
  import type { EventBus } from '@open-mercato/events'
2
2
  import {
3
3
  resolveCatalogPrice,
4
+ resolveCatalogPriceBatch,
4
5
  type PriceRow,
5
6
  type PricingContext,
6
7
  } from '../lib/pricing'
7
8
 
8
9
  export interface CatalogPricingService {
9
10
  resolvePrice(rows: PriceRow[], context: PricingContext): Promise<PriceRow | null>
11
+ resolvePriceMany(entries: Array<{ rows: PriceRow[]; context: PricingContext }>): Promise<Array<PriceRow | null>>
10
12
  }
11
13
 
12
14
  export class DefaultCatalogPricingService implements CatalogPricingService {
@@ -15,6 +17,10 @@ export class DefaultCatalogPricingService implements CatalogPricingService {
15
17
  async resolvePrice(rows: PriceRow[], context: PricingContext): Promise<PriceRow | null> {
16
18
  return resolveCatalogPrice(rows, context, { eventBus: this.eventBus })
17
19
  }
20
+
21
+ async resolvePriceMany(entries: Array<{ rows: PriceRow[]; context: PricingContext }>): Promise<Array<PriceRow | null>> {
22
+ return resolveCatalogPriceBatch(entries, { eventBus: this.eventBus })
23
+ }
18
24
  }
19
25
 
20
26
  export type { PriceRow, PricingContext }
@@ -12,6 +12,7 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
12
12
  import { SendObjectMessageDialog } from '@open-mercato/ui/backend/messages'
13
13
  import { DataLoader } from '@open-mercato/ui/primitives/DataLoader'
14
14
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
15
+ import { RecordNotFoundState, ErrorMessage } from '@open-mercato/ui/backend/detail'
15
16
 
16
17
  type CurrencyData = {
17
18
  id: string
@@ -35,6 +36,7 @@ export default function EditCurrencyPage({ params }: { params?: { id?: string }
35
36
  const [currency, setCurrency] = React.useState<CurrencyData | null>(null)
36
37
  const [loading, setLoading] = React.useState(true)
37
38
  const [error, setError] = React.useState<string | null>(null)
39
+ const [isNotFound, setIsNotFound] = React.useState(false)
38
40
 
39
41
  React.useEffect(() => {
40
42
  async function loadCurrency() {
@@ -42,8 +44,10 @@ export default function EditCurrencyPage({ params }: { params?: { id?: string }
42
44
  const response = await apiCall<{ items: CurrencyData[] }>(`/api/currencies/currencies?id=${params?.id}`)
43
45
  if (response.ok && response.result && response.result.items.length > 0) {
44
46
  setCurrency(response.result.items[0])
47
+ } else if (!response.ok) {
48
+ setError(t('currencies.form.errors.load'))
45
49
  } else {
46
- setError(t('currencies.form.errors.notFound'))
50
+ setIsNotFound(true)
47
51
  }
48
52
  } catch (err) {
49
53
  setError(t('currencies.form.errors.load'))
@@ -163,11 +167,26 @@ export default function EditCurrencyPage({ params }: { params?: { id?: string }
163
167
  )
164
168
  }
165
169
 
170
+ if (isNotFound) {
171
+ return (
172
+ <Page>
173
+ <PageBody>
174
+ <RecordNotFoundState
175
+ label={t('currencies.form.errors.notFound', 'Currency not found.')}
176
+ backHref="/backend/currencies"
177
+ backLabel={t('currencies.form.actions.backToList', 'Back to currencies')}
178
+ />
179
+ </PageBody>
180
+ {ConfirmDialogElement}
181
+ </Page>
182
+ )
183
+ }
184
+
166
185
  if (error || !currency) {
167
186
  return (
168
187
  <Page>
169
188
  <PageBody>
170
- <div className="text-destructive">{error || t('currencies.form.errors.notFound')}</div>
189
+ <ErrorMessage label={error ?? t('currencies.form.errors.notFound', 'Currency not found.')} />
171
190
  </PageBody>
172
191
  {ConfirmDialogElement}
173
192
  </Page>
@@ -41,6 +41,7 @@
41
41
  "currencies.flash.updated": "Währung erfolgreich aktualisiert",
42
42
  "currencies.form.action.create": "Währung erstellen",
43
43
  "currencies.form.action.save": "Änderungen speichern",
44
+ "currencies.form.actions.backToList": "Zurück zu Währungen",
44
45
  "currencies.form.errors.codeFormat": "Währungscode muss genau 3 Großbuchstaben sein (z.B. USD)",
45
46
  "currencies.form.errors.delete": "Währung konnte nicht gelöscht werden",
46
47
  "currencies.form.errors.load": "Währung konnte nicht geladen werden",
@@ -41,6 +41,7 @@
41
41
  "currencies.flash.updated": "Currency updated successfully",
42
42
  "currencies.form.action.create": "Create Currency",
43
43
  "currencies.form.action.save": "Save Changes",
44
+ "currencies.form.actions.backToList": "Back to currencies",
44
45
  "currencies.form.errors.codeFormat": "Currency code must be exactly 3 uppercase letters (e.g., USD)",
45
46
  "currencies.form.errors.delete": "Failed to delete currency",
46
47
  "currencies.form.errors.load": "Failed to load currency",
@@ -41,6 +41,7 @@
41
41
  "currencies.flash.updated": "Moneda actualizada correctamente",
42
42
  "currencies.form.action.create": "Crear Moneda",
43
43
  "currencies.form.action.save": "Guardar Cambios",
44
+ "currencies.form.actions.backToList": "Volver a divisas",
44
45
  "currencies.form.errors.codeFormat": "El código de moneda debe ser exactamente 3 letras mayúsculas (ej. USD)",
45
46
  "currencies.form.errors.delete": "Error al eliminar moneda",
46
47
  "currencies.form.errors.load": "Error al cargar moneda",
@@ -41,6 +41,7 @@
41
41
  "currencies.flash.updated": "Waluta zaktualizowana pomyślnie",
42
42
  "currencies.form.action.create": "Utwórz Walutę",
43
43
  "currencies.form.action.save": "Zapisz Zmiany",
44
+ "currencies.form.actions.backToList": "Wróć do walut",
44
45
  "currencies.form.errors.codeFormat": "Kod waluty musi składać się z dokładnie 3 wielkich liter (np. USD)",
45
46
  "currencies.form.errors.delete": "Nie udało się usunąć waluty",
46
47
  "currencies.form.errors.load": "Nie udało się załadować waluty",
@@ -11,6 +11,7 @@ import { Spinner } from '@open-mercato/ui/primitives/spinner'
11
11
  import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
12
12
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
13
13
  import { useT } from '@open-mercato/shared/lib/i18n/context'
14
+ import { RecordNotFoundState, ErrorMessage } from '@open-mercato/ui/backend/detail'
14
15
 
15
16
  type RoleDetail = {
16
17
  id: string
@@ -146,10 +147,11 @@ export default function CustomerRoleDetailPage({ params }: { params?: { id?: str
146
147
  const [data, setData] = React.useState<RoleDetail | null>(null)
147
148
  const [isLoading, setIsLoading] = React.useState(true)
148
149
  const [error, setError] = React.useState<string | null>(null)
150
+ const [isNotFound, setIsNotFound] = React.useState(false)
149
151
 
150
152
  React.useEffect(() => {
151
153
  if (!id) {
152
- setError(t('customer_accounts.admin.roleDetail.error.notFound', 'Role not found'))
154
+ setIsNotFound(true)
153
155
  setIsLoading(false)
154
156
  return
155
157
  }
@@ -157,6 +159,7 @@ export default function CustomerRoleDetailPage({ params }: { params?: { id?: str
157
159
  async function load() {
158
160
  setIsLoading(true)
159
161
  setError(null)
162
+ setIsNotFound(false)
160
163
  try {
161
164
  const payload = await readApiResultOrThrow<RoleDetail>(
162
165
  `/api/customer_accounts/admin/roles/${encodeURIComponent(id!)}`,
@@ -167,8 +170,12 @@ export default function CustomerRoleDetailPage({ params }: { params?: { id?: str
167
170
  setData(payload)
168
171
  } catch (err) {
169
172
  if (cancelled) return
170
- const message = err instanceof Error ? err.message : t('customer_accounts.admin.roleDetail.error.load', 'Failed to load role')
171
- setError(message)
173
+ if ((err as { status?: number }).status === 404) {
174
+ setIsNotFound(true)
175
+ } else {
176
+ const message = err instanceof Error ? err.message : t('customer_accounts.admin.roleDetail.error.load', 'Failed to load role')
177
+ setError(message)
178
+ }
172
179
  } finally {
173
180
  if (!cancelled) setIsLoading(false)
174
181
  }
@@ -300,18 +307,34 @@ export default function CustomerRoleDetailPage({ params }: { params?: { id?: str
300
307
  )
301
308
  }
302
309
 
310
+ if (isNotFound) {
311
+ return (
312
+ <Page>
313
+ <PageBody>
314
+ <RecordNotFoundState
315
+ label={t('customer_accounts.admin.roleDetail.error.notFound', 'Role not found')}
316
+ backHref="/backend/customer_accounts/roles"
317
+ backLabel={t('customer_accounts.admin.roleDetail.actions.backToList', 'Back to roles')}
318
+ />
319
+ </PageBody>
320
+ </Page>
321
+ )
322
+ }
323
+
303
324
  if (error || !data) {
304
325
  return (
305
326
  <Page>
306
327
  <PageBody>
307
- <div className="flex h-[50vh] flex-col items-center justify-center gap-2 text-muted-foreground">
308
- <p>{error || t('customer_accounts.admin.roleDetail.error.notFound', 'Role not found')}</p>
309
- <Button asChild variant="outline">
310
- <Link href="/backend/customer_accounts/roles">
311
- {t('customer_accounts.admin.roleDetail.actions.backToList', 'Back to roles')}
312
- </Link>
313
- </Button>
314
- </div>
328
+ <ErrorMessage
329
+ label={error ?? t('customer_accounts.admin.roleDetail.error.notFound', 'Role not found')}
330
+ action={
331
+ <Button asChild variant="outline" size="sm">
332
+ <Link href="/backend/customer_accounts/roles">
333
+ {t('customer_accounts.admin.roleDetail.actions.backToList', 'Back to roles')}
334
+ </Link>
335
+ </Button>
336
+ }
337
+ />
315
338
  </PageBody>
316
339
  </Page>
317
340
  )