@open-mercato/core 0.6.4-develop.3996.1.430e257cfc → 0.6.4-develop.4011.1.4f3ed9ae3e

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 (65) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/features.js +24 -2
  3. package/dist/modules/auth/api/features.js.map +2 -2
  4. package/dist/modules/auth/backend/users/[id]/edit/page.js +70 -57
  5. package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
  6. package/dist/modules/auth/components/AclDependencyDiagnosticsPanel.js +135 -0
  7. package/dist/modules/auth/components/AclDependencyDiagnosticsPanel.js.map +7 -0
  8. package/dist/modules/auth/components/AclEditor.js +10 -0
  9. package/dist/modules/auth/components/AclEditor.js.map +2 -2
  10. package/dist/modules/catalog/acl.js +30 -5
  11. package/dist/modules/catalog/acl.js.map +2 -2
  12. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +17 -5
  13. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  14. package/dist/modules/catalog/commands/offers.js +26 -7
  15. package/dist/modules/catalog/commands/offers.js.map +2 -2
  16. package/dist/modules/catalog/commands/prices.js +41 -26
  17. package/dist/modules/catalog/commands/prices.js.map +2 -2
  18. package/dist/modules/catalog/commands/productUnitConversions.js +7 -1
  19. package/dist/modules/catalog/commands/productUnitConversions.js.map +2 -2
  20. package/dist/modules/catalog/commands/products.js +2 -0
  21. package/dist/modules/catalog/commands/products.js.map +2 -2
  22. package/dist/modules/catalog/commands/shared.js +58 -11
  23. package/dist/modules/catalog/commands/shared.js.map +2 -2
  24. package/dist/modules/catalog/commands/variants.js +18 -5
  25. package/dist/modules/catalog/commands/variants.js.map +2 -2
  26. package/dist/modules/customers/acl.js +72 -12
  27. package/dist/modules/customers/acl.js.map +2 -2
  28. package/dist/modules/resources/backend/resources/resources/[id]/page.js +17 -2
  29. package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
  30. package/dist/modules/sales/acl.js +109 -16
  31. package/dist/modules/sales/acl.js.map +2 -2
  32. package/dist/modules/sales/backend/sales/documents/[id]/page.js +20 -1
  33. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  34. package/dist/modules/sales/setup.js +2 -0
  35. package/dist/modules/sales/setup.js.map +2 -2
  36. package/package.json +7 -7
  37. package/src/modules/auth/api/features.ts +34 -4
  38. package/src/modules/auth/backend/users/[id]/edit/page.tsx +28 -6
  39. package/src/modules/auth/components/AclDependencyDiagnosticsPanel.tsx +173 -0
  40. package/src/modules/auth/components/AclEditor.tsx +14 -4
  41. package/src/modules/auth/i18n/de.json +9 -0
  42. package/src/modules/auth/i18n/en.json +9 -0
  43. package/src/modules/auth/i18n/es.json +9 -0
  44. package/src/modules/auth/i18n/pl.json +9 -0
  45. package/src/modules/catalog/acl.ts +30 -5
  46. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +21 -5
  47. package/src/modules/catalog/commands/offers.ts +26 -7
  48. package/src/modules/catalog/commands/prices.ts +41 -26
  49. package/src/modules/catalog/commands/productUnitConversions.ts +7 -1
  50. package/src/modules/catalog/commands/products.ts +2 -0
  51. package/src/modules/catalog/commands/shared.ts +70 -6
  52. package/src/modules/catalog/commands/variants.ts +18 -5
  53. package/src/modules/catalog/i18n/de.json +1 -0
  54. package/src/modules/catalog/i18n/en.json +1 -0
  55. package/src/modules/catalog/i18n/es.json +1 -0
  56. package/src/modules/catalog/i18n/pl.json +1 -0
  57. package/src/modules/customers/acl.ts +72 -12
  58. package/src/modules/resources/backend/resources/resources/[id]/page.tsx +21 -2
  59. package/src/modules/sales/acl.ts +109 -16
  60. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +28 -1
  61. package/src/modules/sales/i18n/de.json +3 -0
  62. package/src/modules/sales/i18n/en.json +3 -0
  63. package/src/modules/sales/i18n/es.json +3 -0
  64. package/src/modules/sales/i18n/pl.json +3 -0
  65. package/src/modules/sales/setup.ts +2 -0
@@ -14,6 +14,7 @@ import {
14
14
  } from '../data/validators'
15
15
  import {
16
16
  cloneJson,
17
+ commandActorScope,
17
18
  ensureOrganizationScope,
18
19
  ensureSameScope,
19
20
  ensureTenantScope,
@@ -98,7 +99,10 @@ const createOfferCommand: CommandHandler<OfferCreateInput, { offerId: string }>
98
99
  ensureTenantScope(ctx, parsed.tenantId)
99
100
  ensureOrganizationScope(ctx, parsed.organizationId)
100
101
  const em = (ctx.container.resolve('em') as EntityManager).fork()
101
- const product = await requireProduct(em, parsed.productId)
102
+ const product = await requireProduct(em, parsed.productId, {
103
+ tenantId: parsed.tenantId,
104
+ organizationId: parsed.organizationId,
105
+ })
102
106
  if (
103
107
  product.organizationId !== parsed.organizationId ||
104
108
  product.tenantId !== parsed.tenantId
@@ -224,10 +228,16 @@ const updateOfferCommand: CommandHandler<OfferUpdateInput, { offerId: string }>
224
228
  ensureOrganizationScope(ctx, record.organizationId)
225
229
  let productEntity =
226
230
  typeof record.product === 'string'
227
- ? await requireProduct(em, record.product)
231
+ ? await requireProduct(em, record.product, {
232
+ tenantId: record.tenantId,
233
+ organizationId: record.organizationId,
234
+ })
228
235
  : record.product
229
236
  if (parsed.productId && parsed.productId !== record.product.id) {
230
- const nextProduct = await requireProduct(em, parsed.productId)
237
+ const nextProduct = await requireProduct(em, parsed.productId, {
238
+ tenantId: record.tenantId,
239
+ organizationId: record.organizationId,
240
+ })
231
241
  ensureSameScope(nextProduct, record.organizationId, record.tenantId)
232
242
  productEntity = nextProduct
233
243
  }
@@ -322,9 +332,15 @@ const updateOfferCommand: CommandHandler<OfferUpdateInput, { offerId: string }>
322
332
  const before = payload?.before
323
333
  if (!before) return
324
334
  const em = (ctx.container.resolve('em') as EntityManager).fork()
325
- const record = await requireOffer(em, before.id).catch(() => null)
335
+ const record = await requireOffer(em, before.id, {
336
+ tenantId: before.tenantId,
337
+ organizationId: before.organizationId,
338
+ }).catch(() => null)
326
339
  if (!record) {
327
- const product = await requireProduct(em, before.productId)
340
+ const product = await requireProduct(em, before.productId, {
341
+ tenantId: before.tenantId,
342
+ organizationId: before.organizationId,
343
+ })
328
344
  ensureSameScope(product, before.organizationId, before.tenantId)
329
345
  const restored = em.create(CatalogOffer, {
330
346
  id: before.id,
@@ -384,7 +400,7 @@ const deleteOfferCommand: CommandHandler<{ id?: string }, { offerId: string }> =
384
400
  async execute(input, ctx) {
385
401
  const parsed = { id: requireId(input, 'Offer id is required.') }
386
402
  const em = (ctx.container.resolve('em') as EntityManager).fork()
387
- const record = await requireOffer(em, parsed.id)
403
+ const record = await requireOffer(em, parsed.id, commandActorScope(ctx))
388
404
  ensureTenantScope(ctx, record.tenantId)
389
405
  ensureOrganizationScope(ctx, record.organizationId)
390
406
  const baseEm = ctx.container.resolve('em') as EntityManager
@@ -436,7 +452,10 @@ const deleteOfferCommand: CommandHandler<{ id?: string }, { offerId: string }> =
436
452
  const em = (ctx.container.resolve('em') as EntityManager).fork()
437
453
  const existing = await em.findOne(CatalogOffer, { id: before.id })
438
454
  if (existing) return
439
- const product = await requireProduct(em, before.productId)
455
+ const product = await requireProduct(em, before.productId, {
456
+ tenantId: before.tenantId,
457
+ organizationId: before.organizationId,
458
+ })
440
459
  ensureSameScope(product, before.organizationId, before.tenantId)
441
460
  const restored = em.create(CatalogOffer, {
442
461
  id: before.id,
@@ -16,6 +16,7 @@ import {
16
16
  import type { TaxCalculationService } from '@open-mercato/core/modules/sales/services/taxCalculationService'
17
17
  import {
18
18
  cloneJson,
19
+ commandActorScope,
19
20
  ensureOrganizationScope,
20
21
  ensureSameScope,
21
22
  ensureSameTenant,
@@ -105,17 +106,18 @@ async function resolveSnapshotAssociations(
105
106
  product: CatalogProduct
106
107
  offer: CatalogOffer | null
107
108
  }> {
109
+ const scope = { tenantId: snapshot.tenantId, organizationId: snapshot.organizationId }
108
110
  let variant: CatalogProductVariant | null = null
109
111
  if (snapshot.variantId) {
110
- variant = await requireVariant(em, snapshot.variantId)
112
+ variant = await requireVariant(em, snapshot.variantId, scope)
111
113
  }
112
114
  let product: CatalogProduct | null = null
113
115
  if (snapshot.productId) {
114
- product = await requireProduct(em, snapshot.productId)
116
+ product = await requireProduct(em, snapshot.productId, scope)
115
117
  } else if (variant) {
116
118
  product =
117
119
  typeof variant.product === 'string'
118
- ? await requireProduct(em, variant.product)
120
+ ? await requireProduct(em, variant.product, scope)
119
121
  : variant.product
120
122
  }
121
123
  if (!product) {
@@ -123,7 +125,7 @@ async function resolveSnapshotAssociations(
123
125
  }
124
126
  let offer: CatalogOffer | null = null
125
127
  if (snapshot.offerId) {
126
- offer = await requireOffer(em, snapshot.offerId)
128
+ offer = await requireOffer(em, snapshot.offerId, scope)
127
129
  }
128
130
  return { variant, product, offer }
129
131
  }
@@ -262,17 +264,21 @@ const createPriceCommand: CommandHandler<PriceCreateInput, { priceId: string }>
262
264
  async execute(rawInput, ctx) {
263
265
  const { parsed, custom } = parseWithCustomFields(priceCreateSchema, rawInput)
264
266
  const em = (ctx.container.resolve('em') as EntityManager).fork()
267
+ const actorScope = commandActorScope(ctx)
265
268
  let variant: CatalogProductVariant | null = null
266
269
  let product: CatalogProduct | null = null
267
270
  if (parsed.variantId) {
268
- variant = await requireVariant(em, parsed.variantId)
271
+ variant = await requireVariant(em, parsed.variantId, actorScope)
269
272
  product =
270
273
  typeof variant.product === 'string'
271
- ? await requireProduct(em, variant.product)
274
+ ? await requireProduct(em, variant.product, {
275
+ tenantId: variant.tenantId,
276
+ organizationId: variant.organizationId,
277
+ })
272
278
  : variant.product
273
279
  }
274
280
  if (parsed.productId) {
275
- const explicitProduct = await requireProduct(em, parsed.productId)
281
+ const explicitProduct = await requireProduct(em, parsed.productId, actorScope)
276
282
  if (product && explicitProduct.id !== product.id) {
277
283
  throw new CrudHttpError(400, { error: 'Variant does not belong to the provided product.' })
278
284
  }
@@ -284,17 +290,24 @@ const createPriceCommand: CommandHandler<PriceCreateInput, { priceId: string }>
284
290
  const scopeSource = variant ?? product!
285
291
  ensureTenantScope(ctx, scopeSource.tenantId)
286
292
  ensureOrganizationScope(ctx, scopeSource.organizationId)
293
+ const scopeSourceScope = {
294
+ tenantId: scopeSource.tenantId,
295
+ organizationId: scopeSource.organizationId,
296
+ }
287
297
 
288
- const priceKind = await requirePriceKind(em, parsed.priceKindId)
298
+ const priceKind = await requirePriceKind(em, parsed.priceKindId, scopeSourceScope)
289
299
  ensureSameTenant(priceKind, scopeSource.tenantId)
290
300
 
291
301
  let offer: CatalogOffer | null = null
292
302
  if (parsed.offerId) {
293
- offer = await requireOffer(em, parsed.offerId)
303
+ offer = await requireOffer(em, parsed.offerId, scopeSourceScope)
294
304
  ensureSameScope(offer, scopeSource.organizationId, scopeSource.tenantId)
295
305
  const offerProduct =
296
306
  typeof offer.product === 'string'
297
- ? await requireProduct(em, offer.product)
307
+ ? await requireProduct(em, offer.product, {
308
+ tenantId: offer.tenantId,
309
+ organizationId: offer.organizationId,
310
+ })
298
311
  : offer.product
299
312
  if (product && offerProduct.id !== product.id) {
300
313
  throw new CrudHttpError(400, { error: 'Offer does not belong to the selected product.' })
@@ -445,17 +458,18 @@ const updatePriceCommand: CommandHandler<PriceUpdateInput, { priceId: string }>
445
458
  { tenantId: parsed.tenantId, organizationId: parsed.organizationId },
446
459
  )
447
460
  if (!record) throw new CrudHttpError(404, { error: 'Catalog price not found' })
461
+ const recordScope = { tenantId: record.tenantId, organizationId: record.organizationId }
448
462
  const currentVariantRef = record.variant
449
463
  let targetVariant: CatalogProductVariant | null = null
450
464
  if (typeof currentVariantRef === 'string') {
451
- targetVariant = await requireVariant(em, currentVariantRef)
465
+ targetVariant = await requireVariant(em, currentVariantRef, recordScope)
452
466
  } else if (currentVariantRef) {
453
467
  targetVariant = currentVariantRef
454
468
  }
455
469
  const currentProductRef = record.product ?? (targetVariant ? targetVariant.product : null)
456
470
  let targetProduct: CatalogProduct | null = null
457
471
  if (typeof currentProductRef === 'string') {
458
- targetProduct = await requireProduct(em, currentProductRef)
472
+ targetProduct = await requireProduct(em, currentProductRef, recordScope)
459
473
  } else if (currentProductRef) {
460
474
  targetProduct = currentProductRef
461
475
  }
@@ -464,23 +478,23 @@ const updatePriceCommand: CommandHandler<PriceUpdateInput, { priceId: string }>
464
478
  if (!parsed.variantId) {
465
479
  targetVariant = null
466
480
  } else {
467
- targetVariant = await requireVariant(em, parsed.variantId)
481
+ targetVariant = await requireVariant(em, parsed.variantId, recordScope)
468
482
  targetProduct =
469
483
  typeof targetVariant.product === 'string'
470
- ? await requireProduct(em, targetVariant.product)
484
+ ? await requireProduct(em, targetVariant.product, recordScope)
471
485
  : targetVariant.product
472
486
  }
473
487
  }
474
488
 
475
489
  if (targetVariant && (targetVariant as CatalogProductVariant | null)?.product === undefined) {
476
- targetVariant = await requireVariant(em, targetVariant.id)
490
+ targetVariant = await requireVariant(em, targetVariant.id, recordScope)
477
491
  }
478
492
 
479
493
  if (parsed.productId !== undefined) {
480
494
  if (!parsed.productId) {
481
495
  targetProduct = null
482
496
  } else {
483
- const explicitProduct = await requireProduct(em, parsed.productId)
497
+ const explicitProduct = await requireProduct(em, parsed.productId, recordScope)
484
498
  if (targetVariant) {
485
499
  const variantProductId =
486
500
  typeof targetVariant.product === 'string'
@@ -500,7 +514,7 @@ const updatePriceCommand: CommandHandler<PriceUpdateInput, { priceId: string }>
500
514
  if (!targetProduct && targetVariant) {
501
515
  targetProduct =
502
516
  typeof targetVariant.product === 'string'
503
- ? await requireProduct(em, targetVariant.product)
517
+ ? await requireProduct(em, targetVariant.product, recordScope)
504
518
  : targetVariant.product
505
519
  }
506
520
  if (!targetProduct) {
@@ -511,14 +525,14 @@ const updatePriceCommand: CommandHandler<PriceUpdateInput, { priceId: string }>
511
525
  if (record.offer) {
512
526
  targetOffer =
513
527
  typeof record.offer === 'string'
514
- ? await requireOffer(em, record.offer)
528
+ ? await requireOffer(em, record.offer, recordScope)
515
529
  : record.offer
516
530
  }
517
531
  if (parsed.offerId !== undefined) {
518
532
  if (!parsed.offerId) {
519
533
  targetOffer = null
520
534
  } else {
521
- const explicitOffer = await requireOffer(em, parsed.offerId)
535
+ const explicitOffer = await requireOffer(em, parsed.offerId, recordScope)
522
536
  ensureSameScope(explicitOffer, targetProduct.organizationId, targetProduct.tenantId)
523
537
  const offerProductId =
524
538
  typeof explicitOffer.product === 'string'
@@ -538,14 +552,14 @@ const updatePriceCommand: CommandHandler<PriceUpdateInput, { priceId: string }>
538
552
  if (record.priceKind) {
539
553
  targetPriceKind =
540
554
  typeof record.priceKind === 'string'
541
- ? await requirePriceKind(em, record.priceKind)
555
+ ? await requirePriceKind(em, record.priceKind, recordScope)
542
556
  : record.priceKind
543
557
  }
544
558
  if (parsed.priceKindId !== undefined) {
545
559
  if (!parsed.priceKindId) {
546
560
  throw new CrudHttpError(400, { error: 'Price kind is required.' })
547
561
  }
548
- targetPriceKind = await requirePriceKind(em, parsed.priceKindId)
562
+ targetPriceKind = await requirePriceKind(em, parsed.priceKindId, recordScope)
549
563
  }
550
564
  if (!targetPriceKind) {
551
565
  throw new CrudHttpError(400, { error: 'Price kind is required.' })
@@ -881,15 +895,16 @@ async function resolvePriceRecordAssociations(
881
895
  em: EntityManager,
882
896
  record: CatalogProductPrice,
883
897
  ): Promise<{ product: CatalogProduct; variant: CatalogProductVariant | null }> {
898
+ const scope = { tenantId: record.tenantId, organizationId: record.organizationId }
884
899
  const variant = record.variant
885
900
  ? (typeof record.variant === 'string'
886
- ? await requireVariant(em, record.variant)
901
+ ? await requireVariant(em, record.variant, scope)
887
902
  : record.variant)
888
903
  : null
889
904
  if (record.product) {
890
905
  const product =
891
906
  typeof record.product === 'string'
892
- ? await requireProduct(em, record.product)
907
+ ? await requireProduct(em, record.product, scope)
893
908
  : record.product
894
909
  return { product, variant }
895
910
  }
@@ -897,20 +912,20 @@ async function resolvePriceRecordAssociations(
897
912
  const productRef = variant.product
898
913
  const product =
899
914
  typeof productRef === 'string'
900
- ? await requireProduct(em, productRef)
915
+ ? await requireProduct(em, productRef, scope)
901
916
  : productRef
902
917
  return { product, variant }
903
918
  }
904
919
  if (record.offer) {
905
920
  const offer =
906
921
  typeof record.offer === 'string'
907
- ? await requireOffer(em, record.offer)
922
+ ? await requireOffer(em, record.offer, scope)
908
923
  : record.offer
909
924
  const productRef = offer?.product ?? null
910
925
  if (productRef) {
911
926
  const product =
912
927
  typeof productRef === 'string'
913
- ? await requireProduct(em, productRef)
928
+ ? await requireProduct(em, productRef, scope)
914
929
  : productRef
915
930
  return { product, variant }
916
931
  }
@@ -247,6 +247,7 @@ const createProductUnitConversionCommand: CommandHandler<
247
247
  const product = await requireProduct(
248
248
  em,
249
249
  parsed.productId,
250
+ { tenantId: parsed.tenantId, organizationId: parsed.organizationId },
250
251
  translate("catalog.errors.productNotFound", "Catalog product not found"),
251
252
  );
252
253
  ensureSameScope(product, parsed.organizationId, parsed.tenantId);
@@ -373,7 +374,12 @@ const updateProductUnitConversionCommand: CommandHandler<
373
374
  });
374
375
  const product =
375
376
  typeof record.product === "string"
376
- ? await requireProduct(em, record.product, translate("catalog.errors.productNotFound", "Catalog product not found"))
377
+ ? await requireProduct(
378
+ em,
379
+ record.product,
380
+ { tenantId: record.tenantId, organizationId: record.organizationId },
381
+ translate("catalog.errors.productNotFound", "Catalog product not found"),
382
+ )
377
383
  : record.product;
378
384
  ensureTenantScope(ctx, record.tenantId);
379
385
  ensureOrganizationScope(ctx, record.organizationId);
@@ -1288,6 +1288,7 @@ const createProductCommand: CommandHandler<
1288
1288
  optionSchemaTemplate = await requireOptionSchemaTemplate(
1289
1289
  em,
1290
1290
  parsed.optionSchemaId,
1291
+ { tenantId: parsed.tenantId, organizationId: parsed.organizationId },
1291
1292
  translate("catalog.errors.optionSchemaNotFound", "Option schema not found"),
1292
1293
  );
1293
1294
  ensureSameScope(
@@ -1591,6 +1592,7 @@ const updateProductCommand: CommandHandler<
1591
1592
  const optionTemplate = await requireOptionSchemaTemplate(
1592
1593
  lookupEm,
1593
1594
  parsed.optionSchemaId,
1595
+ { tenantId, organizationId },
1594
1596
  translate("catalog.errors.optionSchemaNotFound", "Option schema not found"),
1595
1597
  );
1596
1598
  ensureSameScope(optionTemplate, organizationId, tenantId);
@@ -5,7 +5,7 @@ import {
5
5
  CatalogOptionSchemaTemplate,
6
6
  CatalogPriceKind,
7
7
  } from '../data/entities'
8
- import type { EntityManager } from '@mikro-orm/postgresql'
8
+ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
9
9
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
10
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
11
11
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
@@ -73,12 +73,42 @@ export function toNumericString(value: number | null | undefined): string | null
73
73
  return value.toString()
74
74
  }
75
75
 
76
+ export type RequireScope = {
77
+ tenantId: string | null
78
+ organizationId: string | null
79
+ }
80
+
81
+ // Derives the actor's effective tenant/org scope for entry-point lookups, mirroring
82
+ // the bypass semantics of ensureTenantScope/ensureOrganizationScope: tenant is always
83
+ // strict, organization is left unrestricted for super-admins and global-org actors.
84
+ export function commandActorScope(ctx: CommandRuntimeContext): RequireScope {
85
+ const orgUnrestricted = ctx.auth?.isSuperAdmin === true || ctx.organizationScope?.allowedIds === null
86
+ return {
87
+ tenantId: ctx.auth?.tenantId ?? null,
88
+ organizationId: orgUnrestricted ? null : (ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null),
89
+ }
90
+ }
91
+
92
+ function applyScopeToWhere(where: Record<string, unknown>, scope: RequireScope): void {
93
+ if (scope.tenantId != null) where.tenantId = scope.tenantId
94
+ if (scope.organizationId != null) where.organizationId = scope.organizationId
95
+ }
96
+
76
97
  export async function requireProduct(
77
98
  em: EntityManager,
78
99
  id: string,
100
+ scope: RequireScope,
79
101
  message = 'Catalog product not found'
80
102
  ): Promise<CatalogProduct> {
81
- const product = await findOneWithDecryption(em, CatalogProduct, { id, deletedAt: null })
103
+ const where: Record<string, unknown> = { id, deletedAt: null }
104
+ applyScopeToWhere(where, scope)
105
+ const product = await findOneWithDecryption(
106
+ em,
107
+ CatalogProduct,
108
+ where as FilterQuery<CatalogProduct>,
109
+ undefined,
110
+ { tenantId: scope.tenantId, organizationId: scope.organizationId },
111
+ )
82
112
  if (!product) throw new CrudHttpError(404, { error: message })
83
113
  return product
84
114
  }
@@ -86,13 +116,17 @@ export async function requireProduct(
86
116
  export async function requireVariant(
87
117
  em: EntityManager,
88
118
  id: string,
119
+ scope: RequireScope,
89
120
  message = 'Catalog variant not found'
90
121
  ): Promise<CatalogProductVariant> {
122
+ const where: Record<string, unknown> = { id, deletedAt: null }
123
+ applyScopeToWhere(where, scope)
91
124
  const variant = await findOneWithDecryption(
92
125
  em,
93
126
  CatalogProductVariant,
94
- { id, deletedAt: null },
127
+ where as FilterQuery<CatalogProductVariant>,
95
128
  { populate: ['product'] },
129
+ { tenantId: scope.tenantId, organizationId: scope.organizationId },
96
130
  )
97
131
  if (!variant) throw new CrudHttpError(404, { error: message })
98
132
  return variant
@@ -101,9 +135,18 @@ export async function requireVariant(
101
135
  export async function requireOffer(
102
136
  em: EntityManager,
103
137
  id: string,
138
+ scope: RequireScope,
104
139
  message = 'Catalog offer not found'
105
140
  ): Promise<CatalogOffer> {
106
- const offer = await findOneWithDecryption(em, CatalogOffer, { id })
141
+ const where: Record<string, unknown> = { id }
142
+ applyScopeToWhere(where, scope)
143
+ const offer = await findOneWithDecryption(
144
+ em,
145
+ CatalogOffer,
146
+ where as FilterQuery<CatalogOffer>,
147
+ undefined,
148
+ { tenantId: scope.tenantId, organizationId: scope.organizationId },
149
+ )
107
150
  if (!offer) throw new CrudHttpError(404, { error: message })
108
151
  return offer
109
152
  }
@@ -111,9 +154,21 @@ export async function requireOffer(
111
154
  export async function requirePriceKind(
112
155
  em: EntityManager,
113
156
  id: string,
157
+ scope: RequireScope,
114
158
  message = 'Catalog price kind not found'
115
159
  ): Promise<CatalogPriceKind> {
116
- const priceKind = await findOneWithDecryption(em, CatalogPriceKind, { id, deletedAt: null })
160
+ // Price kinds are tenant-global: organization_id is always null and the unique key is
161
+ // (tenant_id, code). Scope by tenant only — applying a concrete org would never match the
162
+ // null row. Tenant scoping still closes the cross-tenant read hole this helper guards.
163
+ const where: Record<string, unknown> = { id, deletedAt: null }
164
+ applyScopeToWhere(where, { tenantId: scope.tenantId, organizationId: null })
165
+ const priceKind = await findOneWithDecryption(
166
+ em,
167
+ CatalogPriceKind,
168
+ where as FilterQuery<CatalogPriceKind>,
169
+ undefined,
170
+ { tenantId: scope.tenantId, organizationId: null },
171
+ )
117
172
  if (!priceKind) throw new CrudHttpError(404, { error: message })
118
173
  return priceKind
119
174
  }
@@ -121,9 +176,18 @@ export async function requirePriceKind(
121
176
  export async function requireOptionSchemaTemplate(
122
177
  em: EntityManager,
123
178
  id: string,
179
+ scope: RequireScope,
124
180
  message = 'Option schema not found'
125
181
  ): Promise<CatalogOptionSchemaTemplate> {
126
- const schema = await findOneWithDecryption(em, CatalogOptionSchemaTemplate, { id, deletedAt: null })
182
+ const where: Record<string, unknown> = { id, deletedAt: null }
183
+ applyScopeToWhere(where, scope)
184
+ const schema = await findOneWithDecryption(
185
+ em,
186
+ CatalogOptionSchemaTemplate,
187
+ where as FilterQuery<CatalogOptionSchemaTemplate>,
188
+ undefined,
189
+ { tenantId: scope.tenantId, organizationId: scope.organizationId },
190
+ )
127
191
  if (!schema) throw new CrudHttpError(404, { error: message })
128
192
  return schema
129
193
  }
@@ -25,6 +25,7 @@ import {
25
25
  } from '../data/validators'
26
26
  import {
27
27
  cloneJson,
28
+ commandActorScope,
28
29
  ensureOrganizationScope,
29
30
  ensureTenantScope,
30
31
  emitCatalogQueryIndexEvent,
@@ -318,7 +319,10 @@ async function restoreVariantPricesFromSnapshots(
318
319
  if (!snapshots.length) return
319
320
  const productRef =
320
321
  typeof variant.product === 'string'
321
- ? await requireProduct(em, variant.product)
322
+ ? await requireProduct(em, variant.product, {
323
+ tenantId: variant.tenantId,
324
+ organizationId: variant.organizationId,
325
+ })
322
326
  : variant.product
323
327
  for (const snapshot of snapshots) {
324
328
  const product =
@@ -547,7 +551,7 @@ const createVariantCommand: CommandHandler<VariantCreateInput, { variantId: stri
547
551
  async execute(rawInput, ctx) {
548
552
  const { parsed, custom } = parseWithCustomFields(variantCreateSchema, rawInput)
549
553
  const em = (ctx.container.resolve('em') as EntityManager).fork()
550
- const product = await requireProduct(em, parsed.productId)
554
+ const product = await requireProduct(em, parsed.productId, commandActorScope(ctx))
551
555
  ensureTenantScope(ctx, product.tenantId)
552
556
  ensureOrganizationScope(ctx, product.organizationId)
553
557
  const { taxRateId, taxRate } = await resolveVariantTaxRate(
@@ -705,7 +709,10 @@ const updateVariantCommand: CommandHandler<VariantUpdateInput, { variantId: stri
705
709
  if (!record) throw new CrudHttpError(404, { error: 'Catalog variant not found' })
706
710
  ensureTenantScope(ctx, record.tenantId)
707
711
  ensureOrganizationScope(ctx, record.organizationId)
708
- const product = await requireProduct(em, record.product.id)
712
+ const product = await requireProduct(em, record.product.id, {
713
+ tenantId: record.tenantId,
714
+ organizationId: record.organizationId,
715
+ })
709
716
 
710
717
  if (!product) throw new CrudHttpError(400, { error: 'Variant product missing' })
711
718
 
@@ -827,7 +834,10 @@ const updateVariantCommand: CommandHandler<VariantUpdateInput, { variantId: stri
827
834
  const em = (ctx.container.resolve('em') as EntityManager).fork()
828
835
  let record = await em.findOne(CatalogProductVariant, { id: before.id })
829
836
  if (!record) {
830
- const product = await requireProduct(em, before.productId)
837
+ const product = await requireProduct(em, before.productId, {
838
+ tenantId: before.tenantId,
839
+ organizationId: before.organizationId,
840
+ })
831
841
  record = em.create(CatalogProductVariant, {
832
842
  id: before.id,
833
843
  product,
@@ -994,7 +1004,10 @@ const deleteVariantCommand: CommandHandler<
994
1004
  const em = (ctx.container.resolve('em') as EntityManager).fork()
995
1005
  let record = await em.findOne(CatalogProductVariant, { id: before.id })
996
1006
  if (!record) {
997
- const product = await requireProduct(em, before.productId)
1007
+ const product = await requireProduct(em, before.productId, {
1008
+ tenantId: before.tenantId,
1009
+ organizationId: before.organizationId,
1010
+ })
998
1011
  record = em.create(CatalogProductVariant, {
999
1012
  id: before.id,
1000
1013
  product,
@@ -361,6 +361,7 @@
361
361
  "catalog.products.create.variantsBuilder.vatColumn": "Steuerklasse",
362
362
  "catalog.products.create.variantsBuilder.vatOptionDefault": "Produkt-Steuerklasse verwenden ({{label}})",
363
363
  "catalog.products.create.variantsBuilder.vatOptionNone": "Keine Steuerklasse",
364
+ "catalog.products.edit.actions.backToList": "Zurück zu Produkten",
364
365
  "catalog.products.edit.custom.title": "Benutzerdefinierte Attribute",
365
366
  "catalog.products.edit.dimensions": "Maße & Gewicht",
366
367
  "catalog.products.edit.dimensions.depth": "Tiefe",
@@ -361,6 +361,7 @@
361
361
  "catalog.products.create.variantsBuilder.vatColumn": "Tax class",
362
362
  "catalog.products.create.variantsBuilder.vatOptionDefault": "Use product tax class ({{label}})",
363
363
  "catalog.products.create.variantsBuilder.vatOptionNone": "No tax class",
364
+ "catalog.products.edit.actions.backToList": "Back to products",
364
365
  "catalog.products.edit.custom.title": "Custom attributes",
365
366
  "catalog.products.edit.dimensions": "Dimensions & weight",
366
367
  "catalog.products.edit.dimensions.depth": "Depth",
@@ -361,6 +361,7 @@
361
361
  "catalog.products.create.variantsBuilder.vatColumn": "Clase de impuesto",
362
362
  "catalog.products.create.variantsBuilder.vatOptionDefault": "Usar clase de impuesto del producto ({{label}})",
363
363
  "catalog.products.create.variantsBuilder.vatOptionNone": "Sin clase de impuesto",
364
+ "catalog.products.edit.actions.backToList": "Volver a productos",
364
365
  "catalog.products.edit.custom.title": "Atributos personalizados",
365
366
  "catalog.products.edit.dimensions": "Dimensiones y peso",
366
367
  "catalog.products.edit.dimensions.depth": "Profundidad",
@@ -361,6 +361,7 @@
361
361
  "catalog.products.create.variantsBuilder.vatColumn": "Klasa podatkowa",
362
362
  "catalog.products.create.variantsBuilder.vatOptionDefault": "Użyj klasy podatkowej produktu ({{label}})",
363
363
  "catalog.products.create.variantsBuilder.vatOptionNone": "Brak klasy podatkowej",
364
+ "catalog.products.edit.actions.backToList": "Powrót do listy produktów",
364
365
  "catalog.products.edit.custom.title": "Atrybuty niestandardowe",
365
366
  "catalog.products.edit.dimensions": "Wymiary i waga",
366
367
  "catalog.products.edit.dimensions.depth": "Głębokość",