@open-mercato/core 0.6.4-develop.4239.1.4a264a5828 → 0.6.4-develop.4264.1.53368d85fe

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 (87) 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/cli.js +6 -0
  11. package/dist/modules/business_rules/cli.js.map +2 -2
  12. package/dist/modules/business_rules/lib/rule-engine.js +116 -9
  13. package/dist/modules/business_rules/lib/rule-engine.js.map +2 -2
  14. package/dist/modules/business_rules/subscribers/crud-rule-trigger.js +3 -2
  15. package/dist/modules/business_rules/subscribers/crud-rule-trigger.js.map +2 -2
  16. package/dist/modules/catalog/api/products/route.js +21 -4
  17. package/dist/modules/catalog/api/products/route.js.map +2 -2
  18. package/dist/modules/catalog/lib/pricing.js +6 -0
  19. package/dist/modules/catalog/lib/pricing.js.map +2 -2
  20. package/dist/modules/catalog/services/catalogPricingService.js +5 -1
  21. package/dist/modules/catalog/services/catalogPricingService.js.map +2 -2
  22. package/dist/modules/customer_accounts/api/portal/events/stream.js +1 -0
  23. package/dist/modules/customer_accounts/api/portal/events/stream.js.map +2 -2
  24. package/dist/modules/customers/api/activities/route.js +15 -2
  25. package/dist/modules/customers/api/activities/route.js.map +2 -2
  26. package/dist/modules/customers/api/comments/route.js +15 -3
  27. package/dist/modules/customers/api/comments/route.js.map +2 -2
  28. package/dist/modules/customers/api/companies/[id]/people/route.js +2 -4
  29. package/dist/modules/customers/api/companies/[id]/people/route.js.map +2 -2
  30. package/dist/modules/customers/api/companies/[id]/route.js +2 -4
  31. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  32. package/dist/modules/customers/api/deals/[id]/companies/route.js +2 -4
  33. package/dist/modules/customers/api/deals/[id]/companies/route.js.map +2 -2
  34. package/dist/modules/customers/api/deals/[id]/people/route.js +2 -4
  35. package/dist/modules/customers/api/deals/[id]/people/route.js.map +2 -2
  36. package/dist/modules/customers/api/deals/[id]/route.js +2 -9
  37. package/dist/modules/customers/api/deals/[id]/route.js.map +2 -2
  38. package/dist/modules/customers/api/deals/[id]/stats/route.js +2 -9
  39. package/dist/modules/customers/api/deals/[id]/stats/route.js.map +2 -2
  40. package/dist/modules/customers/api/entity-roles-factory.js +2 -8
  41. package/dist/modules/customers/api/entity-roles-factory.js.map +2 -2
  42. package/dist/modules/customers/api/people/[id]/companies/context.js +2 -4
  43. package/dist/modules/customers/api/people/[id]/companies/context.js.map +2 -2
  44. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js +2 -4
  45. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js.map +2 -2
  46. package/dist/modules/customers/api/people/[id]/route.js +2 -4
  47. package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
  48. package/dist/modules/directory/utils/organizationScopeGuard.js +22 -0
  49. package/dist/modules/directory/utils/organizationScopeGuard.js.map +7 -0
  50. package/dist/modules/workflows/cli.js +8 -0
  51. package/dist/modules/workflows/cli.js.map +2 -2
  52. package/dist/modules/workflows/lib/seeds.js +8 -4
  53. package/dist/modules/workflows/lib/seeds.js.map +2 -2
  54. package/dist/modules/workflows/setup.js +3 -1
  55. package/dist/modules/workflows/setup.js.map +2 -2
  56. package/package.json +7 -7
  57. package/src/helpers/integration/authFixtures.ts +98 -0
  58. package/src/helpers/integration/dbFixtures.ts +144 -0
  59. package/src/modules/business_rules/api/execute/route.ts +2 -1
  60. package/src/modules/business_rules/api/rules/route.ts +10 -0
  61. package/src/modules/business_rules/cli.ts +6 -0
  62. package/src/modules/business_rules/lib/rule-engine.ts +163 -9
  63. package/src/modules/business_rules/subscribers/crud-rule-trigger.ts +3 -2
  64. package/src/modules/catalog/api/products/route.ts +23 -4
  65. package/src/modules/catalog/lib/pricing.ts +9 -0
  66. package/src/modules/catalog/services/catalogPricingService.ts +6 -0
  67. package/src/modules/customer_accounts/api/portal/events/stream.ts +6 -0
  68. package/src/modules/customers/api/activities/route.ts +16 -5
  69. package/src/modules/customers/api/comments/route.ts +15 -5
  70. package/src/modules/customers/api/companies/[id]/people/route.ts +2 -4
  71. package/src/modules/customers/api/companies/[id]/route.ts +2 -5
  72. package/src/modules/customers/api/deals/[id]/companies/route.ts +2 -4
  73. package/src/modules/customers/api/deals/[id]/people/route.ts +2 -4
  74. package/src/modules/customers/api/deals/[id]/route.ts +2 -9
  75. package/src/modules/customers/api/deals/[id]/stats/route.ts +2 -9
  76. package/src/modules/customers/api/entity-roles-factory.ts +2 -12
  77. package/src/modules/customers/api/people/[id]/companies/context.ts +2 -5
  78. package/src/modules/customers/api/people/[id]/companies/enriched/route.ts +2 -5
  79. package/src/modules/customers/api/people/[id]/route.ts +2 -5
  80. package/src/modules/directory/utils/organizationScopeGuard.ts +39 -0
  81. package/src/modules/staff/i18n/de.json +23 -0
  82. package/src/modules/staff/i18n/en.json +23 -0
  83. package/src/modules/staff/i18n/es.json +23 -0
  84. package/src/modules/staff/i18n/pl.json +23 -0
  85. package/src/modules/workflows/cli.ts +8 -0
  86. package/src/modules/workflows/lib/seeds.ts +13 -3
  87. package/src/modules/workflows/setup.ts +3 -1
@@ -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
  }
@@ -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 }
@@ -173,6 +173,12 @@ export async function GET(req: Request): Promise<Response> {
173
173
  }
174
174
  portalConnections.add(connection)
175
175
 
176
+ // Flush an initial comment so the runtime sends the response headers and
177
+ // first body byte immediately, firing the browser EventSource `open`
178
+ // event without waiting for the first heartbeat (30s) or matching event.
179
+ // Comment lines (`:` prefix) are ignored by EventSource message parsing.
180
+ controller.enqueue(encoder.encode(': connected\n\n'))
181
+
176
182
  heartbeatTimer = setInterval(() => {
177
183
  try {
178
184
  controller.enqueue(encoder.encode(':heartbeat\n\n'))
@@ -185,7 +185,7 @@ function paginateActivityItems(
185
185
  }
186
186
  }
187
187
 
188
- async function decorateActivityItems(
188
+ export async function decorateActivityItems(
189
189
  em: EntityManager,
190
190
  items: ActivityItem[],
191
191
  decryptionScope?: { tenantId: string; organizationId: string },
@@ -207,12 +207,23 @@ async function decorateActivityItems(
207
207
  ),
208
208
  )
209
209
 
210
+ if (dealIds.length > 0 && (!decryptionScope?.tenantId || !decryptionScope?.organizationId)) {
211
+ const { translate } = await resolveTranslations()
212
+ throw new CrudHttpError(400, {
213
+ error: translate('customers.errors.tenant_required', 'Tenant context is required'),
214
+ })
215
+ }
216
+
210
217
  const [users, deals] = await Promise.all([
211
218
  authorIds.length > 0 ? em.find(User, { id: { $in: authorIds } }) : Promise.resolve([]),
212
- dealIds.length > 0
213
- ? decryptionScope
214
- ? findWithDecryption(em, CustomerDeal, { id: { $in: dealIds } }, undefined, decryptionScope)
215
- : em.find(CustomerDeal, { id: { $in: dealIds } })
219
+ dealIds.length > 0 && decryptionScope
220
+ ? findWithDecryption(
221
+ em,
222
+ CustomerDeal,
223
+ { id: { $in: dealIds }, tenantId: decryptionScope.tenantId, organizationId: decryptionScope.organizationId },
224
+ undefined,
225
+ decryptionScope,
226
+ )
216
227
  : Promise.resolve([]),
217
228
  ])
218
229
 
@@ -139,13 +139,23 @@ const crud = makeCrudRoute({
139
139
  ),
140
140
  )
141
141
  if (!dealIds.length) return
142
+ const tenantId = ctx.auth?.tenantId ?? null
143
+ const organizationId = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
144
+ if (!tenantId || !organizationId) {
145
+ const { translate } = await resolveTranslations()
146
+ throw new CrudHttpError(400, {
147
+ error: translate('customers.errors.tenant_required', 'Tenant context is required'),
148
+ })
149
+ }
142
150
  try {
143
151
  const em = (ctx.container.resolve('em') as EntityManager)
144
- const tenantId = ctx.auth?.tenantId ?? null
145
- const organizationId = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
146
- const deals = tenantId && organizationId
147
- ? await findWithDecryption(em, CustomerDeal, { id: { $in: dealIds } }, undefined, { tenantId, organizationId })
148
- : await em.find(CustomerDeal, { id: { $in: dealIds } })
152
+ const deals = await findWithDecryption(
153
+ em,
154
+ CustomerDeal,
155
+ { id: { $in: dealIds }, tenantId, organizationId },
156
+ undefined,
157
+ { tenantId, organizationId },
158
+ )
149
159
  const map = new Map<string, string>()
150
160
  deals.forEach((deal: CustomerDeal) => {
151
161
  if (deal.id) map.set(deal.id, deal.title ?? '')
@@ -8,6 +8,7 @@ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/d
8
8
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
9
9
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
10
10
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
11
+ import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
11
12
  import {
12
13
  CustomerEntity,
13
14
  CustomerPersonCompanyLink,
@@ -115,10 +116,7 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
115
116
  throw new CrudHttpError(404, { error: translate('customers.errors.company_not_found', 'Company not found') })
116
117
  }
117
118
 
118
- const allowedOrgIds = new Set<string>()
119
- if (scope?.filterIds?.length) scope.filterIds.forEach((entry) => allowedOrgIds.add(entry))
120
- else if (auth.orgId) allowedOrgIds.add(auth.orgId)
121
- if (allowedOrgIds.size > 0 && company.organizationId && !allowedOrgIds.has(company.organizationId)) {
119
+ if (!isOrganizationReadAccessAllowed({ scope, auth, organizationId: company.organizationId })) {
122
120
  throw new CrudHttpError(403, { error: translate('customers.errors.access_denied', 'Access denied') })
123
121
  }
124
122
 
@@ -47,6 +47,7 @@ import {
47
47
  withActiveCustomerPersonCompanyLinkFilter,
48
48
  } from '../../../lib/personCompanyLinkTable'
49
49
  import { normalizeCustomerDetailCustomFields } from '../../detailCustomFields'
50
+ import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
50
51
 
51
52
  export const metadata = {
52
53
  GET: { requireAuth: true, requireFeatures: ['customers.companies.view'] },
@@ -386,11 +387,7 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
386
387
  if (!company) return notFound('Company not found')
387
388
 
388
389
  if (auth.tenantId && company.tenantId !== auth.tenantId) return notFound('Company not found')
389
- const allowedOrgIds = new Set<string>()
390
- if (scope?.filterIds?.length) scope.filterIds.forEach((id) => allowedOrgIds.add(id))
391
- else if (auth.orgId) allowedOrgIds.add(auth.orgId)
392
-
393
- if (allowedOrgIds.size && company.organizationId && !allowedOrgIds.has(company.organizationId)) {
390
+ if (!isOrganizationReadAccessAllowed({ scope, auth, organizationId: company.organizationId })) {
394
391
  return forbidden('Access denied')
395
392
  }
396
393
 
@@ -8,6 +8,7 @@ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/d
8
8
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
9
9
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
10
10
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
11
+ import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
11
12
  import { CustomerCompanyProfile, CustomerDeal, CustomerDealCompanyLink, CustomerEntity } from '../../../../data/entities'
12
13
 
13
14
  const paramsSchema = z.object({
@@ -97,10 +98,7 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
97
98
  throw new CrudHttpError(404, { error: translate('customers.errors.deal_not_found', 'Deal not found') })
98
99
  }
99
100
 
100
- const allowedOrgIds = new Set<string>()
101
- if (scope?.filterIds?.length) scope.filterIds.forEach((entry) => allowedOrgIds.add(entry))
102
- else if (auth.orgId) allowedOrgIds.add(auth.orgId)
103
- if (allowedOrgIds.size > 0 && deal.organizationId && !allowedOrgIds.has(deal.organizationId)) {
101
+ if (!isOrganizationReadAccessAllowed({ scope, auth, organizationId: deal.organizationId })) {
104
102
  throw new CrudHttpError(403, { error: translate('customers.errors.access_denied', 'Access denied') })
105
103
  }
106
104
 
@@ -8,6 +8,7 @@ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/d
8
8
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
9
9
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
10
10
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
11
+ import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
11
12
  import { CustomerDeal, CustomerDealPersonLink, CustomerEntity } from '../../../../data/entities'
12
13
 
13
14
  const paramsSchema = z.object({
@@ -97,10 +98,7 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
97
98
  throw new CrudHttpError(404, { error: translate('customers.errors.deal_not_found', 'Deal not found') })
98
99
  }
99
100
 
100
- const allowedOrgIds = new Set<string>()
101
- if (scope?.filterIds?.length) scope.filterIds.forEach((entry) => allowedOrgIds.add(entry))
102
- else if (auth.orgId) allowedOrgIds.add(auth.orgId)
103
- if (allowedOrgIds.size > 0 && deal.organizationId && !allowedOrgIds.has(deal.organizationId)) {
101
+ if (!isOrganizationReadAccessAllowed({ scope, auth, organizationId: deal.organizationId })) {
104
102
  throw new CrudHttpError(403, { error: translate('customers.errors.access_denied', 'Access denied') })
105
103
  }
106
104
 
@@ -22,6 +22,7 @@ import { E } from '#generated/entities.ids.generated'
22
22
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
23
23
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
24
24
  import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
25
+ import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
25
26
  import { decryptEntitiesWithFallbackScope } from '@open-mercato/shared/lib/encryption/subscriber'
26
27
  import { isMissingDealStageTransitionTable, warnMissingDealStageTransitionTable } from '../../../lib/dealStageTransitionTable'
27
28
 
@@ -377,15 +378,7 @@ export async function GET(request: Request, context: { params?: Record<string, u
377
378
  return notFound('Deal not found')
378
379
  }
379
380
 
380
- const allowedOrgIds = new Set<string>()
381
- if (Array.isArray(scope?.filterIds)) {
382
- scope.filterIds.forEach((id) => {
383
- if (typeof id === 'string' && id.trim().length) allowedOrgIds.add(id)
384
- })
385
- } else if (auth.orgId) {
386
- allowedOrgIds.add(auth.orgId)
387
- }
388
- if (allowedOrgIds.size && deal.organizationId && !allowedOrgIds.has(deal.organizationId)) {
381
+ if (!isOrganizationReadAccessAllowed({ scope, auth, organizationId: deal.organizationId })) {
389
382
  return forbidden('Access denied')
390
383
  }
391
384
 
@@ -10,6 +10,7 @@ import { DictionaryEntry } from '@open-mercato/core/modules/dictionaries/data/en
10
10
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
11
11
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
12
12
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
13
+ import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
13
14
 
14
15
  export const metadata = {
15
16
  GET: { requireAuth: true, requireFeatures: ['customers.deals.view'] },
@@ -97,15 +98,7 @@ export async function GET(request: Request, context: { params?: Record<string, u
97
98
  return notFound(translate('customers.errors.deal_not_found', 'Deal not found'))
98
99
  }
99
100
 
100
- const allowedOrgIds = new Set<string>()
101
- if (Array.isArray(scope?.filterIds)) {
102
- scope.filterIds.forEach((id) => {
103
- if (typeof id === 'string' && id.trim().length) allowedOrgIds.add(id)
104
- })
105
- } else if (auth.orgId) {
106
- allowedOrgIds.add(auth.orgId)
107
- }
108
- if (allowedOrgIds.size && deal.organizationId && !allowedOrgIds.has(deal.organizationId)) {
101
+ if (!isOrganizationReadAccessAllowed({ scope, auth, organizationId: deal.organizationId })) {
109
102
  return forbidden(translate('customers.errors.access_denied', 'Access denied'))
110
103
  }
111
104
 
@@ -7,6 +7,7 @@ import { validateCrudMutationGuard, runCrudMutationGuardAfterSuccess } from '@op
7
7
  import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
8
8
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
9
9
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
10
+ import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
10
11
  import { User } from '@open-mercato/core/modules/auth/data/entities'
11
12
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
12
13
  import { CustomerEntity, CustomerEntityRole } from '../data/entities'
@@ -69,24 +70,13 @@ async function buildContext(request: Request) {
69
70
  }
70
71
  }
71
72
 
72
- function collectAllowedOrganizationIds(
73
- scope: Awaited<ReturnType<typeof resolveCustomersRequestContext>>['scope'],
74
- auth: Awaited<ReturnType<typeof resolveCustomersRequestContext>>['auth'],
75
- ) {
76
- const allowedOrgIds = new Set<string>()
77
- if (scope?.filterIds?.length) scope.filterIds.forEach((id) => allowedOrgIds.add(id))
78
- else if (auth.orgId) allowedOrgIds.add(auth.orgId)
79
- return allowedOrgIds
80
- }
81
-
82
73
  function ensureRouteOrganizationAccess(
83
74
  organizationId: string,
84
75
  scope: Awaited<ReturnType<typeof resolveCustomersRequestContext>>['scope'],
85
76
  auth: Awaited<ReturnType<typeof resolveCustomersRequestContext>>['auth'],
86
77
  translate: Translator,
87
78
  ) {
88
- const allowedOrgIds = collectAllowedOrganizationIds(scope, auth)
89
- if (allowedOrgIds.size > 0 && !allowedOrgIds.has(organizationId)) {
79
+ if (!isOrganizationReadAccessAllowed({ scope, auth, organizationId })) {
90
80
  throw new CrudHttpError(403, { error: translate('customers.errors.access_denied', 'Access denied') })
91
81
  }
92
82
  }
@@ -7,6 +7,7 @@ import {
7
7
  CustomerPersonProfile,
8
8
  } from '@open-mercato/core/modules/customers/data/entities'
9
9
  import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
10
+ import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
10
11
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
11
12
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
12
13
 
@@ -37,11 +38,7 @@ export async function loadPersonContext(req: Request, personId: string) {
37
38
  throw new CrudHttpError(404, { error: translate('customers.errors.person_not_found', 'Person not found') })
38
39
  }
39
40
 
40
- const allowedOrgIds = new Set<string>()
41
- if (scope?.filterIds?.length) scope.filterIds.forEach((entry) => allowedOrgIds.add(entry))
42
- else if (authenticatedAuth.orgId) allowedOrgIds.add(authenticatedAuth.orgId)
43
-
44
- if (allowedOrgIds.size > 0 && !allowedOrgIds.has(person.organizationId)) {
41
+ if (!isOrganizationReadAccessAllowed({ scope, auth: authenticatedAuth, organizationId: person.organizationId })) {
45
42
  throw new CrudHttpError(403, { error: translate('customers.errors.access_denied', 'Access denied') })
46
43
  }
47
44
 
@@ -8,6 +8,7 @@ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/d
8
8
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
9
9
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
10
10
  import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
11
+ import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
11
12
  import {
12
13
  CustomerEntity,
13
14
  CustomerCompanyProfile,
@@ -173,11 +174,7 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
173
174
  throw new CrudHttpError(404, { error: translate('customers.errors.person_not_found', 'Person not found') })
174
175
  }
175
176
 
176
- const allowedOrgIds = new Set<string>()
177
- if (scope?.filterIds?.length) scope.filterIds.forEach((entry) => allowedOrgIds.add(entry))
178
- else if (auth.orgId) allowedOrgIds.add(auth.orgId)
179
-
180
- if (allowedOrgIds.size > 0 && !allowedOrgIds.has(person.organizationId)) {
177
+ if (!isOrganizationReadAccessAllowed({ scope, auth, organizationId: person.organizationId })) {
181
178
  throw new CrudHttpError(403, { error: translate('customers.errors.access_denied', 'Access denied') })
182
179
  }
183
180