@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
@@ -16,6 +16,7 @@ import { flash } from '@open-mercato/ui/backend/FlashMessages'
16
16
  import { useT } from '@open-mercato/shared/lib/i18n/context'
17
17
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
18
18
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
19
+ import { RecordNotFoundState, ErrorMessage } from '@open-mercato/ui/backend/detail'
19
20
 
20
21
  type UserDetail = {
21
22
  id: string
@@ -147,6 +148,7 @@ export default function CustomerUserDetailPage({ params }: { params?: { id?: str
147
148
  const [data, setData] = React.useState<UserDetail | null>(null)
148
149
  const [isLoading, setIsLoading] = React.useState(true)
149
150
  const [error, setError] = React.useState<string | null>(null)
151
+ const [isNotFound, setIsNotFound] = React.useState(false)
150
152
  const [isSaving, setIsSaving] = React.useState(false)
151
153
  const [editActive, setEditActive] = React.useState<boolean | null>(null)
152
154
  const [editDisplayName, setEditDisplayName] = React.useState('')
@@ -186,7 +188,7 @@ export default function CustomerUserDetailPage({ params }: { params?: { id?: str
186
188
 
187
189
  React.useEffect(() => {
188
190
  if (!id) {
189
- setError(t('customer_accounts.admin.detail.error.notFound', 'User not found'))
191
+ setIsNotFound(true)
190
192
  setIsLoading(false)
191
193
  return
192
194
  }
@@ -194,6 +196,7 @@ export default function CustomerUserDetailPage({ params }: { params?: { id?: str
194
196
  async function load() {
195
197
  setIsLoading(true)
196
198
  setError(null)
199
+ setIsNotFound(false)
197
200
  try {
198
201
  const payload = await readApiResultOrThrow<UserDetail>(
199
202
  `/api/customer_accounts/admin/users/${encodeURIComponent(id!)}`,
@@ -209,8 +212,12 @@ export default function CustomerUserDetailPage({ params }: { params?: { id?: str
209
212
  setEditCustomerEntityId(payload.customerEntityId)
210
213
  } catch (err) {
211
214
  if (cancelled) return
212
- const message = err instanceof Error ? err.message : t('customer_accounts.admin.detail.error.load', 'Failed to load user')
213
- setError(message)
215
+ if ((err as { status?: number }).status === 404) {
216
+ setIsNotFound(true)
217
+ } else {
218
+ const message = err instanceof Error ? err.message : t('customer_accounts.admin.detail.error.load', 'Failed to load user')
219
+ setError(message)
220
+ }
214
221
  } finally {
215
222
  if (!cancelled) setIsLoading(false)
216
223
  }
@@ -463,18 +470,34 @@ export default function CustomerUserDetailPage({ params }: { params?: { id?: str
463
470
  )
464
471
  }
465
472
 
473
+ if (isNotFound) {
474
+ return (
475
+ <Page>
476
+ <PageBody>
477
+ <RecordNotFoundState
478
+ label={t('customer_accounts.admin.detail.error.notFound', 'User not found')}
479
+ backHref="/backend/customer_accounts/users"
480
+ backLabel={t('customer_accounts.admin.detail.actions.backToList', 'Back to list')}
481
+ />
482
+ </PageBody>
483
+ </Page>
484
+ )
485
+ }
486
+
466
487
  if (error || !data) {
467
488
  return (
468
489
  <Page>
469
490
  <PageBody>
470
- <div className="flex h-[50vh] flex-col items-center justify-center gap-2 text-muted-foreground">
471
- <p>{error || t('customer_accounts.admin.detail.error.notFound', 'User not found')}</p>
472
- <Button asChild variant="outline">
473
- <Link href="/backend/customer_accounts/users">
474
- {t('customer_accounts.admin.detail.actions.backToList', 'Back to list')}
475
- </Link>
476
- </Button>
477
- </div>
491
+ <ErrorMessage
492
+ label={error ?? t('customer_accounts.admin.detail.error.notFound', 'User not found')}
493
+ action={
494
+ <Button asChild variant="outline" size="sm">
495
+ <Link href="/backend/customer_accounts/users">
496
+ {t('customer_accounts.admin.detail.actions.backToList', 'Back to list')}
497
+ </Link>
498
+ </Button>
499
+ }
500
+ />
478
501
  </PageBody>
479
502
  </Page>
480
503
  )
@@ -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
 
@@ -37,6 +37,7 @@ import type { EntityId } from '@open-mercato/shared/modules/entities'
37
37
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
38
38
  import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
39
39
  import { parseBooleanFromUnknown, parseBooleanToken } from '@open-mercato/shared/lib/boolean'
40
+ import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
40
41
  import { loadPersonCompanyLinks, summarizePersonCompanies } from '../../../lib/personCompanies'
41
42
  import { normalizeCustomerDetailCustomFields } from '../../detailCustomFields'
42
43
 
@@ -474,11 +475,7 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
474
475
  profileMeta = { reason: 'person_tenant_mismatch' }
475
476
  return notFound('Person not found')
476
477
  }
477
- const allowedOrgIds = new Set<string>()
478
- if (scope?.filterIds?.length) scope.filterIds.forEach((id) => allowedOrgIds.add(id))
479
- else if (auth.orgId) allowedOrgIds.add(auth.orgId)
480
-
481
- if (allowedOrgIds.size && person.organizationId && !allowedOrgIds.has(person.organizationId)) {
478
+ if (!isOrganizationReadAccessAllowed({ scope, auth, organizationId: person.organizationId })) {
482
479
  statusCode = 403
483
480
  profileMeta = { reason: 'organization_forbidden' }
484
481
  return forbidden('Access denied')
@@ -20,6 +20,8 @@ import {
20
20
  NotesSection,
21
21
  type CommentSummary,
22
22
  type SectionAction,
23
+ RecordNotFoundState,
24
+ ErrorMessage,
23
25
  } from '@open-mercato/ui/backend/detail'
24
26
  import {
25
27
  TagsSection,
@@ -116,6 +118,7 @@ export default function CustomerPersonDetailPage({ params }: { params?: { id?: s
116
118
  const [data, setData] = React.useState<PersonOverview | null>(null)
117
119
  const [isLoading, setIsLoading] = React.useState(true)
118
120
  const [error, setError] = React.useState<string | null>(null)
121
+ const [isNotFound, setIsNotFound] = React.useState(false)
119
122
  const [activeTab, setActiveTab] = React.useState<SectionKey>(initialTab)
120
123
  const [sectionAction, setSectionAction] = React.useState<SectionAction | null>(null)
121
124
  const [isDeleting, setIsDeleting] = React.useState(false)
@@ -276,7 +279,7 @@ export default function CustomerPersonDetailPage({ params }: { params?: { id?: s
276
279
  const initialLoadDoneRef = React.useRef(false)
277
280
  const loadData = React.useCallback(async () => {
278
281
  if (!id) {
279
- setError(t('customers.people.detail.error.notFound'))
282
+ setIsNotFound(true)
280
283
  setIsLoading(false)
281
284
  return
282
285
  }
@@ -284,6 +287,7 @@ export default function CustomerPersonDetailPage({ params }: { params?: { id?: s
284
287
  setIsLoading(true)
285
288
  }
286
289
  setError(null)
290
+ setIsNotFound(false)
287
291
  try {
288
292
  const payload = await readApiResultOrThrow<PersonOverview>(
289
293
  `/api/customers/people/${encodeURIComponent(id)}?include=todos`,
@@ -292,8 +296,12 @@ export default function CustomerPersonDetailPage({ params }: { params?: { id?: s
292
296
  )
293
297
  setData(payload as PersonOverview)
294
298
  } catch (err) {
295
- const message = err instanceof Error ? err.message : t('customers.people.detail.error.load')
296
- setError(message)
299
+ if ((err as { status?: number }).status === 404) {
300
+ setIsNotFound(true)
301
+ } else {
302
+ const message = err instanceof Error ? err.message : t('customers.people.detail.error.load')
303
+ setError(message)
304
+ }
297
305
  if (!initialLoadDoneRef.current) setData(null)
298
306
  } finally {
299
307
  setIsLoading(false)
@@ -469,18 +477,34 @@ export default function CustomerPersonDetailPage({ params }: { params?: { id?: s
469
477
  )
470
478
  }
471
479
 
480
+ if (isNotFound) {
481
+ return (
482
+ <Page>
483
+ <PageBody>
484
+ <RecordNotFoundState
485
+ label={t('customers.people.detail.error.notFound', 'Person not found.')}
486
+ backHref="/backend/customers/people"
487
+ backLabel={t('customers.people.detail.actions.backToList', 'Back to people')}
488
+ />
489
+ </PageBody>
490
+ </Page>
491
+ )
492
+ }
493
+
472
494
  if (error || !data || !personId) {
473
495
  return (
474
496
  <Page>
475
497
  <PageBody>
476
- <div className="flex h-[50vh] flex-col items-center justify-center gap-2 text-muted-foreground">
477
- <p>{error || t('customers.people.detail.error.notFound')}</p>
478
- <Button asChild variant="outline">
479
- <Link href="/backend/customers/people">
480
- {t('customers.people.detail.actions.backToList')}
481
- </Link>
482
- </Button>
483
- </div>
498
+ <ErrorMessage
499
+ label={error ?? t('customers.people.detail.error.notFound', 'Person not found.')}
500
+ action={
501
+ <Button asChild variant="outline" size="sm">
502
+ <Link href="/backend/customers/people">
503
+ {t('customers.people.detail.actions.backToList', 'Back to people')}
504
+ </Link>
505
+ </Button>
506
+ }
507
+ />
484
508
  </PageBody>
485
509
  </Page>
486
510
  )
@@ -0,0 +1,39 @@
1
+ import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
2
+ import { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'
3
+ import type { OrganizationScope } from './organizationScope'
4
+
5
+ export type OrganizationReadAccessInput = {
6
+ scope: OrganizationScope | null | undefined
7
+ auth: AuthContext
8
+ organizationId: string | null
9
+ }
10
+
11
+ /**
12
+ * Fail-closed read guard for single-record detail routes. Centralizes the
13
+ * decision so callers keep their own deny mechanism (throw / return response)
14
+ * and their own i18n key.
15
+ *
16
+ * Unrestricted access (super admin or `scope.allowedIds === null`) is the only
17
+ * bypass. For a restricted principal the allowed set is derived the same way
18
+ * the detail routes always have (`filterIds` narrows the active view, else the
19
+ * principal's home org); an empty derived set denies instead of skipping.
20
+ */
21
+ export function isOrganizationReadAccessAllowed(input: OrganizationReadAccessInput): boolean {
22
+ const isSuperAdmin = input.auth?.isSuperAdmin === true
23
+ if (isSuperAdmin || input.scope?.allowedIds === null) return true
24
+
25
+ const allowedOrganizationIds = new Set<string>()
26
+ if (input.scope?.filterIds?.length) {
27
+ for (const id of input.scope.filterIds) {
28
+ if (typeof id === 'string' && id.trim().length) allowedOrganizationIds.add(id)
29
+ }
30
+ } else if (input.auth?.orgId) {
31
+ allowedOrganizationIds.add(input.auth.orgId)
32
+ }
33
+
34
+ return isOrganizationAccessAllowed({
35
+ isSuperAdmin,
36
+ allowedOrganizationIds: Array.from(allowedOrganizationIds),
37
+ targetOrganizationId: input.organizationId,
38
+ })
39
+ }
@@ -8,21 +8,25 @@ export const features = [
8
8
  id: 'progress.create',
9
9
  title: 'Create progress jobs',
10
10
  module: 'progress',
11
+ dependsOn: ['progress.view'],
11
12
  },
12
13
  {
13
14
  id: 'progress.update',
14
15
  title: 'Update progress jobs',
15
16
  module: 'progress',
17
+ dependsOn: ['progress.view'],
16
18
  },
17
19
  {
18
20
  id: 'progress.cancel',
19
21
  title: 'Cancel progress jobs',
20
22
  module: 'progress',
23
+ dependsOn: ['progress.view'],
21
24
  },
22
25
  {
23
26
  id: 'progress.manage',
24
27
  title: 'Manage all progress jobs',
25
28
  module: 'progress',
29
+ dependsOn: ['progress.view'],
26
30
  },
27
31
  ]
28
32
 
@@ -11,6 +11,7 @@ import { Button } from '@open-mercato/ui/primitives/button'
11
11
  import { FormHeader } from '@open-mercato/ui/backend/forms'
12
12
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
13
13
  import { JsonDisplay } from '@open-mercato/ui/backend/JsonDisplay'
14
+ import { RecordNotFoundState, ErrorMessage } from '@open-mercato/ui/backend/detail'
14
15
 
15
16
  type WorkflowEvent = {
16
17
  id: string
@@ -55,8 +56,13 @@ export default function WorkflowEventDetailPage() {
55
56
  const response = await apiFetch(`/api/workflows/events/${eventId}`)
56
57
 
57
58
  if (!response.ok) {
58
- const errorData = await response.json().catch(() => ({}))
59
- throw new Error(t('workflows.events.messages.loadFailed'))
59
+ const httpErr = new Error(
60
+ response.status === 404
61
+ ? t('workflows.events.notFound', 'Event not found.')
62
+ : t('workflows.events.messages.loadFailed')
63
+ ) as Error & { status: number }
64
+ httpErr.status = response.status
65
+ throw httpErr
60
66
  }
61
67
  const result = await response.json()
62
68
  return result as WorkflowEvent
@@ -77,18 +83,34 @@ export default function WorkflowEventDetailPage() {
77
83
  )
78
84
  }
79
85
 
86
+ const isNotFound = !isLoading && (error as (Error & { status?: number }) | null)?.status === 404
87
+
88
+ if (isNotFound) {
89
+ return (
90
+ <Page>
91
+ <PageBody>
92
+ <RecordNotFoundState
93
+ label={t('workflows.events.notFound', 'Event not found.')}
94
+ backHref="/backend/events"
95
+ backLabel={t('workflows.events.backToList', 'Back to Events')}
96
+ />
97
+ </PageBody>
98
+ </Page>
99
+ )
100
+ }
101
+
80
102
  if (error || !event) {
81
103
  return (
82
104
  <Page>
83
105
  <PageBody>
84
- <div className="flex h-[50vh] flex-col items-center justify-center gap-2 text-muted-foreground">
85
- <p>{error ? t('workflows.events.messages.loadFailed') : t('workflows.events.notFound')}</p>
86
- <Button asChild variant="outline">
87
- <Link href="/backend/events">
88
- {t('workflows.events.backToList')}
89
- </Link>
90
- </Button>
91
- </div>
106
+ <ErrorMessage
107
+ label={(error as Error | null)?.message ?? t('workflows.events.messages.loadFailed')}
108
+ action={
109
+ <Button asChild variant="outline" size="sm">
110
+ <Link href="/backend/events">{t('workflows.events.backToList', 'Back to Events')}</Link>
111
+ </Button>
112
+ }
113
+ />
92
114
  </PageBody>
93
115
  </Page>
94
116
  )