@open-mercato/core 0.6.3-develop.3876.1.d40fe4ec2d → 0.6.3-develop.3894.1.352abf4240

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 (140) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/api/file/[id]/route.js +7 -2
  3. package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
  4. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +7 -4
  5. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
  6. package/dist/modules/audit_logs/services/accessLogService.js +127 -8
  7. package/dist/modules/audit_logs/services/accessLogService.js.map +2 -2
  8. package/dist/modules/auth/backend/auth/profile/page.js +1 -1
  9. package/dist/modules/auth/backend/auth/profile/page.js.map +2 -2
  10. package/dist/modules/auth/backend/profile/change-password/page.js +1 -1
  11. package/dist/modules/auth/backend/profile/change-password/page.js.map +2 -2
  12. package/dist/modules/auth/backend/users/[id]/edit/page.js +1 -1
  13. package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
  14. package/dist/modules/auth/backend/users/create/page.js +6 -1
  15. package/dist/modules/auth/backend/users/create/page.js.map +2 -2
  16. package/dist/modules/auth/di.js +17 -3
  17. package/dist/modules/auth/di.js.map +2 -2
  18. package/dist/modules/auth/services/rbacDefaultCache.js +110 -0
  19. package/dist/modules/auth/services/rbacDefaultCache.js.map +7 -0
  20. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +8 -1
  21. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  22. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js +3 -2
  23. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js.map +2 -2
  24. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/create/page.js +3 -2
  25. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/create/page.js.map +2 -2
  26. package/dist/modules/configs/cli.js +27 -14
  27. package/dist/modules/configs/cli.js.map +2 -2
  28. package/dist/modules/currencies/api/currencies/route.js +3 -4
  29. package/dist/modules/currencies/api/currencies/route.js.map +2 -2
  30. package/dist/modules/currencies/api/exchange-rates/route.js +3 -4
  31. package/dist/modules/currencies/api/exchange-rates/route.js.map +2 -2
  32. package/dist/modules/customers/api/people/route.js +26 -24
  33. package/dist/modules/customers/api/people/route.js.map +2 -2
  34. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +26 -0
  35. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +7 -0
  36. package/dist/modules/directory/utils/organizationScope.js +85 -0
  37. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  38. package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js +1 -1
  39. package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js.map +2 -2
  40. package/dist/modules/sales/backend/sales/channels/[channelId]/edit/page.js +1 -1
  41. package/dist/modules/sales/backend/sales/channels/[channelId]/edit/page.js.map +2 -2
  42. package/dist/modules/sales/components/channels/ChannelOfferForm.js +1 -1
  43. package/dist/modules/sales/components/channels/ChannelOfferForm.js.map +2 -2
  44. package/dist/modules/workflows/backend/definitions/[id]/page.js +2 -1
  45. package/dist/modules/workflows/backend/definitions/[id]/page.js.map +2 -2
  46. package/dist/modules/workflows/backend/definitions/create/page.js +4 -2
  47. package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
  48. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +20 -3
  49. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  50. package/dist/modules/workflows/components/ActivitiesEditor.js +34 -1
  51. package/dist/modules/workflows/components/ActivitiesEditor.js.map +2 -2
  52. package/dist/modules/workflows/components/NodeEditDialog.js +153 -17
  53. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  54. package/dist/modules/workflows/components/StepsEditor.js +31 -0
  55. package/dist/modules/workflows/components/StepsEditor.js.map +2 -2
  56. package/dist/modules/workflows/components/WorkflowGraph.js +3 -2
  57. package/dist/modules/workflows/components/WorkflowGraph.js.map +2 -2
  58. package/dist/modules/workflows/components/nodes/WaitForTimerNode.js +54 -0
  59. package/dist/modules/workflows/components/nodes/WaitForTimerNode.js.map +7 -0
  60. package/dist/modules/workflows/components/nodes/index.js +3 -1
  61. package/dist/modules/workflows/components/nodes/index.js.map +2 -2
  62. package/dist/modules/workflows/data/validators.js +117 -0
  63. package/dist/modules/workflows/data/validators.js.map +2 -2
  64. package/dist/modules/workflows/di.js +5 -1
  65. package/dist/modules/workflows/di.js.map +2 -2
  66. package/dist/modules/workflows/lib/activity-executor.js +42 -1
  67. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  68. package/dist/modules/workflows/lib/activity-queue-types.js.map +2 -2
  69. package/dist/modules/workflows/lib/activity-worker-handler.js +24 -0
  70. package/dist/modules/workflows/lib/activity-worker-handler.js.map +2 -2
  71. package/dist/modules/workflows/lib/duration.js +32 -0
  72. package/dist/modules/workflows/lib/duration.js.map +7 -0
  73. package/dist/modules/workflows/lib/event-logger.js +1 -0
  74. package/dist/modules/workflows/lib/event-logger.js.map +2 -2
  75. package/dist/modules/workflows/lib/format-validation-error.js +12 -0
  76. package/dist/modules/workflows/lib/format-validation-error.js.map +7 -0
  77. package/dist/modules/workflows/lib/graph-utils.js +6 -3
  78. package/dist/modules/workflows/lib/graph-utils.js.map +2 -2
  79. package/dist/modules/workflows/lib/node-type-icons.js +9 -5
  80. package/dist/modules/workflows/lib/node-type-icons.js.map +2 -2
  81. package/dist/modules/workflows/lib/signal-handler.js +55 -23
  82. package/dist/modules/workflows/lib/signal-handler.js.map +2 -2
  83. package/dist/modules/workflows/lib/step-handler.js +79 -29
  84. package/dist/modules/workflows/lib/step-handler.js.map +2 -2
  85. package/dist/modules/workflows/lib/timer-handler.js +159 -0
  86. package/dist/modules/workflows/lib/timer-handler.js.map +7 -0
  87. package/dist/modules/workflows/lib/workflow-executor.js +1 -1
  88. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  89. package/dist/modules/workflows/workers/workflow-activities.worker.js +20 -4
  90. package/dist/modules/workflows/workers/workflow-activities.worker.js.map +2 -2
  91. package/package.json +7 -7
  92. package/src/modules/attachments/api/file/[id]/route.ts +7 -2
  93. package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +7 -4
  94. package/src/modules/audit_logs/services/accessLogService.ts +179 -15
  95. package/src/modules/auth/backend/auth/profile/page.tsx +1 -1
  96. package/src/modules/auth/backend/profile/change-password/page.tsx +1 -1
  97. package/src/modules/auth/backend/users/[id]/edit/page.tsx +1 -1
  98. package/src/modules/auth/backend/users/create/page.tsx +6 -1
  99. package/src/modules/auth/di.ts +26 -3
  100. package/src/modules/auth/services/rbacDefaultCache.ts +145 -0
  101. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +8 -1
  102. package/src/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.tsx +3 -2
  103. package/src/modules/catalog/backend/catalog/products/[productId]/variants/create/page.tsx +3 -2
  104. package/src/modules/configs/cli.ts +34 -13
  105. package/src/modules/currencies/api/currencies/route.ts +3 -4
  106. package/src/modules/currencies/api/exchange-rates/route.ts +3 -4
  107. package/src/modules/customers/api/people/route.ts +27 -25
  108. package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +39 -0
  109. package/src/modules/directory/utils/organizationScope.ts +121 -0
  110. package/src/modules/resources/backend/resources/resource-types/[id]/edit/page.tsx +1 -1
  111. package/src/modules/sales/backend/sales/channels/[channelId]/edit/page.tsx +1 -1
  112. package/src/modules/sales/components/channels/ChannelOfferForm.tsx +1 -1
  113. package/src/modules/workflows/backend/definitions/[id]/page.tsx +3 -2
  114. package/src/modules/workflows/backend/definitions/create/page.tsx +4 -2
  115. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +18 -1
  116. package/src/modules/workflows/components/ActivitiesEditor.tsx +40 -0
  117. package/src/modules/workflows/components/NodeEditDialog.tsx +218 -30
  118. package/src/modules/workflows/components/StepsEditor.tsx +36 -0
  119. package/src/modules/workflows/components/WorkflowGraph.tsx +2 -1
  120. package/src/modules/workflows/components/nodes/WaitForTimerNode.tsx +70 -0
  121. package/src/modules/workflows/components/nodes/index.ts +3 -0
  122. package/src/modules/workflows/data/validators.ts +121 -0
  123. package/src/modules/workflows/di.ts +4 -0
  124. package/src/modules/workflows/i18n/de.json +10 -1
  125. package/src/modules/workflows/i18n/en.json +10 -1
  126. package/src/modules/workflows/i18n/es.json +10 -1
  127. package/src/modules/workflows/i18n/pl.json +10 -1
  128. package/src/modules/workflows/lib/activity-executor.ts +86 -2
  129. package/src/modules/workflows/lib/activity-queue-types.ts +18 -11
  130. package/src/modules/workflows/lib/activity-worker-handler.ts +29 -0
  131. package/src/modules/workflows/lib/duration.ts +51 -0
  132. package/src/modules/workflows/lib/event-logger.ts +1 -0
  133. package/src/modules/workflows/lib/format-validation-error.ts +30 -0
  134. package/src/modules/workflows/lib/graph-utils.ts +3 -0
  135. package/src/modules/workflows/lib/node-type-icons.ts +6 -2
  136. package/src/modules/workflows/lib/signal-handler.ts +62 -24
  137. package/src/modules/workflows/lib/step-handler.ts +107 -50
  138. package/src/modules/workflows/lib/timer-handler.ts +213 -0
  139. package/src/modules/workflows/lib/workflow-executor.ts +1 -1
  140. package/src/modules/workflows/workers/workflow-activities.worker.ts +33 -7
@@ -0,0 +1,145 @@
1
+ import type { CacheStrategy, CacheValue, CacheSetOptions, CacheGetOptions } from '@open-mercato/cache'
2
+
3
+ /**
4
+ * Process-scoped fallback CacheStrategy for RbacService.
5
+ *
6
+ * Used only when no shared `cache` service is registered in DI
7
+ * (CLI scripts, lean test bootstraps, isolated unit harnesses). Production
8
+ * deployments wire `@open-mercato/cache` via bootstrap.ts, which preempts
9
+ * this fallback.
10
+ *
11
+ * Goals:
12
+ * - Match the CacheStrategy contract that RbacService consumes (`get`,
13
+ * `set`, `has`, `delete`, `deleteByTags`, `clear`).
14
+ * - Bound memory: LRU eviction at MAX_ENTRIES.
15
+ * - Honor `OM_RBAC_DEFAULT_CACHE=off` so callers can disable it explicitly.
16
+ * - Stay process-scoped via `globalThis` so HMR / module duplication does
17
+ * not produce divergent caches (same pattern as registerDiRegistrars).
18
+ */
19
+
20
+ type FallbackEntry = {
21
+ value: CacheValue
22
+ tags: string[]
23
+ expiresAt: number | null
24
+ }
25
+
26
+ type FallbackCache = CacheStrategy & {
27
+ __reset: () => void
28
+ }
29
+
30
+ const GLOBAL_KEY = '__openMercatoRbacFallbackCache__'
31
+ const MAX_ENTRIES = 5000
32
+
33
+ export function isRbacDefaultCacheEnabled(): boolean {
34
+ // Default OFF — same gating posture as Phases 2/4/5 in this PR. The
35
+ // integration runtime stays on the bare `asClass(RbacService).scoped()`
36
+ // path (matching develop) unless an operator opts in explicitly.
37
+ // Set `OM_RBAC_DEFAULT_CACHE=on` (or `1`/`true`/`yes`) to enable the
38
+ // in-process LRU fallback.
39
+ const raw = process.env.OM_RBAC_DEFAULT_CACHE
40
+ if (raw === undefined) return false
41
+ const normalized = raw.trim().toLowerCase()
42
+ if (!normalized.length) return false
43
+ return normalized === 'on' || normalized === '1' || normalized === 'true' || normalized === 'yes'
44
+ }
45
+
46
+ function nowMs(): number {
47
+ return Date.now()
48
+ }
49
+
50
+ function createCache(): FallbackCache {
51
+ const store = new Map<string, FallbackEntry>()
52
+ const touch = (key: string) => {
53
+ const entry = store.get(key)
54
+ if (!entry) return undefined
55
+ if (entry.expiresAt !== null && entry.expiresAt < nowMs()) {
56
+ store.delete(key)
57
+ return undefined
58
+ }
59
+ // Move to most-recently-used position (Map preserves insertion order).
60
+ store.delete(key)
61
+ store.set(key, entry)
62
+ return entry
63
+ }
64
+ const evictIfNeeded = () => {
65
+ while (store.size > MAX_ENTRIES) {
66
+ const oldest = store.keys().next().value
67
+ if (typeof oldest !== 'string') break
68
+ store.delete(oldest)
69
+ }
70
+ }
71
+ const cache: FallbackCache = {
72
+ async get(key: string, _options?: CacheGetOptions): Promise<CacheValue | null> {
73
+ const entry = touch(key)
74
+ return entry ? entry.value : null
75
+ },
76
+ async set(key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> {
77
+ const ttl = options?.ttl ?? null
78
+ store.delete(key)
79
+ store.set(key, {
80
+ value,
81
+ tags: options?.tags ?? [],
82
+ expiresAt: typeof ttl === 'number' && ttl > 0 ? nowMs() + ttl : null,
83
+ })
84
+ evictIfNeeded()
85
+ },
86
+ async has(key: string): Promise<boolean> {
87
+ return touch(key) !== undefined
88
+ },
89
+ async delete(key: string): Promise<boolean> {
90
+ return store.delete(key)
91
+ },
92
+ async deleteByTags(tags: string[]): Promise<number> {
93
+ if (!tags.length) return 0
94
+ const tagSet = new Set(tags)
95
+ let removed = 0
96
+ for (const [key, entry] of store.entries()) {
97
+ if (entry.tags.some((tag) => tagSet.has(tag))) {
98
+ store.delete(key)
99
+ removed += 1
100
+ }
101
+ }
102
+ return removed
103
+ },
104
+ async clear(): Promise<number> {
105
+ const count = store.size
106
+ store.clear()
107
+ return count
108
+ },
109
+ async keys(pattern?: string): Promise<string[]> {
110
+ if (!pattern) return Array.from(store.keys())
111
+ const matcher = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')
112
+ const regex = new RegExp(`^${matcher}$`)
113
+ return Array.from(store.keys()).filter((key) => regex.test(key))
114
+ },
115
+ async size(): Promise<number> {
116
+ return store.size
117
+ },
118
+ async stats(): Promise<{ size: number; expired: number }> {
119
+ const now = nowMs()
120
+ let expired = 0
121
+ for (const entry of store.values()) {
122
+ if (entry.expiresAt !== null && entry.expiresAt < now) expired += 1
123
+ }
124
+ return { size: store.size, expired }
125
+ },
126
+ __reset() {
127
+ store.clear()
128
+ },
129
+ } as FallbackCache
130
+ return cache
131
+ }
132
+
133
+ export function createRbacFallbackCache(): CacheStrategy {
134
+ const existing = (globalThis as any)[GLOBAL_KEY] as FallbackCache | undefined
135
+ if (existing) return existing
136
+ const cache = createCache()
137
+ ;(globalThis as any)[GLOBAL_KEY] = cache
138
+ return cache
139
+ }
140
+
141
+ /** Test-only helper. Clears the process-scoped fallback cache. */
142
+ export function resetRbacFallbackCache(): void {
143
+ const existing = (globalThis as any)[GLOBAL_KEY] as FallbackCache | undefined
144
+ existing?.__reset()
145
+ }
@@ -556,7 +556,14 @@ export default function EditCatalogProductPage({
556
556
  const productRes = await apiCall<ProductResponse>(
557
557
  `/api/catalog/products?id=${encodeURIComponent(productId!)}&page=1&pageSize=1&withDeleted=false`,
558
558
  );
559
- if (!productRes.ok) throw new Error("load_failed");
559
+ if (!productRes.ok) {
560
+ throw new Error(
561
+ t(
562
+ "catalog.products.edit.errors.load",
563
+ "Failed to load product details.",
564
+ ),
565
+ );
566
+ }
560
567
  const record = Array.isArray(productRes.result?.items)
561
568
  ? productRes.result?.items?.[0]
562
569
  : undefined;
@@ -9,6 +9,7 @@ import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors
9
9
  import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customFieldValues'
10
10
  import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
11
11
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
12
+ import { ErrorMessage } from '@open-mercato/ui/backend/detail'
12
13
  import { useT } from '@open-mercato/shared/lib/i18n/context'
13
14
  import { extractCustomFieldEntries } from '@open-mercato/shared/lib/crud/custom-fields-client'
14
15
  import { E } from '#generated/entities.ids.generated'
@@ -165,7 +166,7 @@ export default function EditVariantPage({ params }: { params?: { productId?: str
165
166
  const variantRes = await apiCall<VariantResponse>(
166
167
  `/api/catalog/variants?id=${encodeURIComponent(variantId!)}&page=1&pageSize=1`,
167
168
  )
168
- if (!variantRes.ok) throw new Error('load_variant_failed')
169
+ if (!variantRes.ok) throw new Error(t('catalog.variants.form.errors.load', 'Failed to load variant.'))
169
170
  const record = Array.isArray(variantRes.result?.items) ? variantRes.result?.items?.[0] : undefined
170
171
  if (!record) throw new Error(t('catalog.variants.form.errors.notFound', 'Variant not found.'))
171
172
  const resolvedProductId =
@@ -421,7 +422,7 @@ export default function EditVariantPage({ params }: { params?: { productId?: str
421
422
  <Page>
422
423
  <PageBody>
423
424
  {error ? (
424
- <div className="mb-4 rounded border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">{error}</div>
425
+ <ErrorMessage label={error} className="mb-4" />
425
426
  ) : null}
426
427
  <CrudForm<VariantFormValues>
427
428
  title={formTitle}
@@ -9,6 +9,7 @@ import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors
9
9
  import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customFieldValues'
10
10
  import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
11
11
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
12
+ import { ErrorMessage } from '@open-mercato/ui/backend/detail'
12
13
  import { useT } from '@open-mercato/shared/lib/i18n/context'
13
14
  import { E } from '#generated/entities.ids.generated'
14
15
  import {
@@ -136,7 +137,7 @@ export default function CreateVariantPage({ params }: { params?: { productId?: s
136
137
  const res = await apiCall<ProductResponse>(
137
138
  `/api/catalog/products?id=${encodeURIComponent(productId!)}&page=1&pageSize=1`,
138
139
  )
139
- if (!res.ok) throw new Error('load_failed')
140
+ if (!res.ok) throw new Error(t('catalog.variants.form.errors.load', 'Failed to load product context.'))
140
141
  const record = Array.isArray(res.result?.items) ? res.result?.items?.[0] : undefined
141
142
  if (!record) throw new Error(t('catalog.products.edit.errors.notFound', 'Product not found.'))
142
143
  const metadata = (record.metadata ?? {}) as Record<string, unknown>
@@ -298,7 +299,7 @@ export default function CreateVariantPage({ params }: { params?: { productId?: s
298
299
  <Page>
299
300
  <PageBody>
300
301
  {error ? (
301
- <div className="mb-4 rounded border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">{error}</div>
302
+ <ErrorMessage label={error} className="mb-4" />
302
303
  ) : null}
303
304
  <CrudForm<VariantFormValues>
304
305
  title={formTitle}
@@ -16,6 +16,12 @@ import { touchGeneratedBarrels } from './lib/touchGeneratedBarrels'
16
16
 
17
17
  type ParsedArgs = Record<string, string | boolean>
18
18
 
19
+ export const STRUCTURAL_CACHE_REQUESTS: CachePurgeRequest[] = [
20
+ { kind: 'pattern', pattern: 'nav:*' },
21
+ { kind: 'segment', segment: 'admin-nav' },
22
+ { kind: 'segment', segment: 'portal-nav' },
23
+ ]
24
+
19
25
  type CacheScope = {
20
26
  label: string
21
27
  tenantId: string | null
@@ -157,7 +163,7 @@ function printCacheHelp() {
157
163
  console.log('ℹ️ Notes:')
158
164
  console.log(' `stats` mirrors the cache admin page segment overview for CRUD/widget caches.')
159
165
  console.log(' `purge --id` removes every key whose name contains the provided token (for example a user id or entity id).')
160
- console.log(' `structural` targets navigation caches (`nav:*`) and is the recommended post-step after module/sidebar structure changes.')
166
+ console.log(' `structural` targets navigation/sidebar caches and is the recommended post-step after module/sidebar structure changes.')
161
167
  console.log(' When no scope flag is supplied, this command uses the global cache scope only.')
162
168
  }
163
169
 
@@ -201,11 +207,14 @@ async function runCacheStats(args: ParsedArgs) {
201
207
  }
202
208
  }
203
209
 
204
- async function runCachePurge(args: ParsedArgs) {
210
+ async function runCachePurgeRequest(
211
+ args: ParsedArgs,
212
+ request: CachePurgeRequest,
213
+ emitOutput = true,
214
+ ) {
205
215
  const json = flagEnabled(args, 'json')
206
216
  const quiet = flagEnabled(args, 'quiet')
207
217
  const dryRun = flagEnabled(args, 'dry-run', 'dryRun')
208
- const request = resolveCachePurgeRequest(args)
209
218
  const container = await createRequestContainer()
210
219
  try {
211
220
  const em = container.resolve('em') as EntityManager
@@ -228,13 +237,13 @@ async function runCachePurge(args: ParsedArgs) {
228
237
  })
229
238
  }
230
239
 
231
- if (json) {
240
+ if (json && emitOutput) {
232
241
  console.log(JSON.stringify(results, null, 2))
233
- return
242
+ return results
234
243
  }
235
244
 
236
- if (quiet) {
237
- return
245
+ if (quiet || !emitOutput) {
246
+ return results
238
247
  }
239
248
 
240
249
  for (const result of results) {
@@ -246,22 +255,34 @@ async function runCachePurge(args: ParsedArgs) {
246
255
  }
247
256
  }
248
257
  }
258
+ return results
249
259
  } finally {
250
260
  await disposeContainer(container)
251
261
  }
252
262
  }
253
263
 
264
+ async function runCachePurge(args: ParsedArgs) {
265
+ await runCachePurgeRequest(args, resolveCachePurgeRequest(args))
266
+ }
267
+
254
268
  async function runStructuralCachePurge(args: ParsedArgs) {
255
- const nextArgs: ParsedArgs = {
256
- ...args,
257
- pattern: 'nav:*',
269
+ const json = flagEnabled(args, 'json')
270
+ const structuralResults: Array<{
271
+ request: CachePurgeRequest
272
+ results: Awaited<ReturnType<typeof runCachePurgeRequest>>
273
+ }> = []
274
+ for (const request of STRUCTURAL_CACHE_REQUESTS) {
275
+ const results = await runCachePurgeRequest(args, request, !json)
276
+ structuralResults.push({ request, results })
277
+ }
278
+ if (json) {
279
+ console.log(JSON.stringify(structuralResults, null, 2))
258
280
  }
259
- await runCachePurge(nextArgs)
260
281
  const quiet = flagEnabled(args, 'quiet')
261
282
  try {
262
- touchGeneratedBarrels({ quiet })
283
+ touchGeneratedBarrels({ quiet: quiet || json })
263
284
  } catch (err) {
264
- if (!quiet) {
285
+ if (!quiet && !json) {
265
286
  console.warn(
266
287
  `[structural] failed to touch generated barrels: ${(err as Error).message ?? err}`,
267
288
  )
@@ -172,10 +172,9 @@ export async function GET(req: Request) {
172
172
  orderBy.code = 'ASC'
173
173
  }
174
174
 
175
- const [all, total] = await em.findAndCount(Currency, filter, { orderBy })
176
- const start = (page - 1) * pageSize
177
- const paged = all.slice(start, start + pageSize)
178
- const items = paged.map(toRow)
175
+ const offset = (page - 1) * pageSize
176
+ const [rows, total] = await em.findAndCount(Currency, filter, { orderBy, limit: pageSize, offset })
177
+ const items = rows.map(toRow)
179
178
  const totalPages = Math.max(1, Math.ceil(total / pageSize))
180
179
 
181
180
  return NextResponse.json({ items, total, page, pageSize, totalPages })
@@ -169,10 +169,9 @@ export async function GET(req: Request) {
169
169
  orderBy.date = 'DESC'
170
170
  }
171
171
 
172
- const [all, total] = await em.findAndCount(ExchangeRate, where, { orderBy })
173
- const start = (page - 1) * pageSize
174
- const paged = all.slice(start, start + pageSize)
175
- const items = paged.map(toRow)
172
+ const offset = (page - 1) * pageSize
173
+ const [rows, total] = await em.findAndCount(ExchangeRate, where, { orderBy, limit: pageSize, offset })
174
+ const items = rows.map(toRow)
176
175
  const totalPages = Math.max(1, Math.ceil(total / pageSize))
177
176
 
178
177
  return NextResponse.json({ items, total, page, pageSize, totalPages })
@@ -427,37 +427,39 @@ const crud = makeCrudRoute({
427
427
  tenantId: ctx.auth?.tenantId ?? null,
428
428
  organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
429
429
  }
430
- const entities = await findWithDecryption(
431
- em,
432
- CustomerEntity,
433
- {
434
- id: { $in: ids },
435
- deletedAt: null,
436
- kind: 'person',
437
- } as FilterQuery<CustomerEntity>,
438
- undefined,
439
- decryptionScope,
440
- )
441
- const entitiesById = new Map<string, CustomerEntity>()
442
- for (const entity of entities) {
443
- entitiesById.set(entity.id, entity)
444
- }
445
-
446
- const where: Record<string, unknown> = {
430
+ const profileWhere: Record<string, unknown> = {
447
431
  entity: { $in: ids },
448
432
  tenantId: ctx.auth?.tenantId ?? null,
449
433
  }
450
434
  if (ctx.selectedOrganizationId) {
451
- where.organizationId = ctx.selectedOrganizationId
435
+ profileWhere.organizationId = ctx.selectedOrganizationId
452
436
  }
453
437
 
454
- const profiles = await findWithDecryption(
455
- em,
456
- CustomerPersonProfile,
457
- where as FilterQuery<CustomerPersonProfile>,
458
- { populate: ['entity', 'company'] },
459
- decryptionScope,
460
- )
438
+ const [entities, profiles] = await Promise.all([
439
+ findWithDecryption(
440
+ em,
441
+ CustomerEntity,
442
+ {
443
+ id: { $in: ids },
444
+ deletedAt: null,
445
+ kind: 'person',
446
+ } as FilterQuery<CustomerEntity>,
447
+ undefined,
448
+ decryptionScope,
449
+ ),
450
+ findWithDecryption(
451
+ em,
452
+ CustomerPersonProfile,
453
+ profileWhere as FilterQuery<CustomerPersonProfile>,
454
+ { populate: ['entity', 'company'] },
455
+ decryptionScope,
456
+ ),
457
+ ])
458
+
459
+ const entitiesById = new Map<string, CustomerEntity>()
460
+ for (const entity of entities) {
461
+ entitiesById.set(entity.id, entity)
462
+ }
461
463
 
462
464
  const profilesByEntityId = new Map<string, CustomerPersonProfile>()
463
465
  for (const profile of profiles) {
@@ -0,0 +1,39 @@
1
+ // Invalidate the OrganizationScope cache when an organization mutates.
2
+ //
3
+ // resolveOrganizationScopeForRequest caches its result with a short TTL
4
+ // (default 60s, OM_ORG_SCOPE_CACHE_TTL_MS). When an organization is
5
+ // created/updated/deleted, the cached scope for users of the affected
6
+ // tenant may be stale (visibility set or descendant tree changed). We
7
+ // drop every cache entry tagged for that tenant; the TTL is the backstop
8
+ // for races where the event fires after a request reads the cache.
9
+
10
+ type CacheService = {
11
+ deleteByTags(tags: string[]): Promise<number>
12
+ }
13
+
14
+ export const metadata = {
15
+ event: 'directory.organization.*',
16
+ persistent: false,
17
+ id: 'directory:invalidate-org-scope-cache',
18
+ }
19
+
20
+ export default async function handle(
21
+ payload: unknown,
22
+ ctx: { resolve: <T = unknown>(name: string) => T },
23
+ ): Promise<void> {
24
+ const data = (payload ?? {}) as Record<string, unknown>
25
+ const tenantId = typeof data.tenantId === 'string' ? data.tenantId : null
26
+ if (!tenantId) return
27
+ let cache: CacheService | null = null
28
+ try {
29
+ cache = ctx.resolve<CacheService>('cache')
30
+ } catch {
31
+ return
32
+ }
33
+ if (!cache) return
34
+ try {
35
+ await cache.deleteByTags([`org-scope:tenant:${tenantId}`])
36
+ } catch {
37
+ // best-effort; TTL is the backstop.
38
+ }
39
+ }
@@ -5,6 +5,7 @@ import { Organization } from '@open-mercato/core/modules/directory/data/entities
5
5
  import { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'
6
6
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
7
  import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
8
+ import type { CacheStrategy } from '@open-mercato/cache'
8
9
  import { parseSelectedOrganizationCookie, parseSelectedTenantCookie } from './scopeCookies'
9
10
 
10
11
  export { parseSelectedOrganizationCookie, parseSelectedTenantCookie }
@@ -16,6 +17,90 @@ export type OrganizationScope = {
16
17
  tenantId: string | null
17
18
  }
18
19
 
20
+ // Phase 4 — short-TTL cache for resolveOrganizationScopeForRequest.
21
+ // OrganizationScope is a pure function of (userId, tenantId, selectedOrgId,
22
+ // requestedTenant) between membership changes; caching it bypasses 1
23
+ // SELECT on `organizations` per CRUD request. TTL is short (60s default)
24
+ // to keep staleness bounded for membership/visibility changes. Tag-based
25
+ // invalidation kicks the cache when user_organizations or organizations
26
+ // mutate (wired via invalidateOrganizationScopeCacheFor).
27
+ const ORG_SCOPE_CACHE_KEY_PREFIX = 'org-scope'
28
+ // Phase 4 default-off until the same readiness probe (`GET /api/customers/people`)
29
+ // stays green with the cache layer engaged. Set `OM_ORG_SCOPE_CACHE_TTL_MS=60000`
30
+ // (or any positive integer) to opt in once cross-request safety is re-verified.
31
+ const ORG_SCOPE_DEFAULT_TTL_MS = 0
32
+
33
+ function resolveOrgScopeTtlMs(): number {
34
+ const raw = process.env.OM_ORG_SCOPE_CACHE_TTL_MS
35
+ if (raw === undefined) return ORG_SCOPE_DEFAULT_TTL_MS
36
+ const parsed = Number(raw)
37
+ if (!Number.isFinite(parsed) || parsed < 0) return ORG_SCOPE_DEFAULT_TTL_MS
38
+ return parsed
39
+ }
40
+
41
+ function buildOrgScopeCacheKey(parts: {
42
+ userId: string
43
+ effectiveTenantId: string
44
+ selectedOrgId: string | null
45
+ requestedTenantId: string | null
46
+ }): string {
47
+ const selected = parts.selectedOrgId ?? 'none'
48
+ const requested = parts.requestedTenantId ?? 'none'
49
+ return `${ORG_SCOPE_CACHE_KEY_PREFIX}:${parts.userId}:${parts.effectiveTenantId}:${selected}:${requested}`
50
+ }
51
+
52
+ function buildOrgScopeCacheTags(parts: { userId: string; effectiveTenantId: string }): string[] {
53
+ return [
54
+ `${ORG_SCOPE_CACHE_KEY_PREFIX}:user:${parts.userId}`,
55
+ `${ORG_SCOPE_CACHE_KEY_PREFIX}:tenant:${parts.effectiveTenantId}`,
56
+ ]
57
+ }
58
+
59
+ function isValidCachedScope(value: unknown): value is OrganizationScope {
60
+ if (typeof value !== 'object' || value === null) return false
61
+ const record = value as Partial<OrganizationScope>
62
+ const idOk = (v: unknown) => v === null || typeof v === 'string'
63
+ const arrOk = (v: unknown) => v === null || (Array.isArray(v) && v.every((entry) => typeof entry === 'string'))
64
+ return idOk(record.selectedId) && idOk(record.tenantId) && arrOk(record.filterIds) && arrOk(record.allowedIds)
65
+ }
66
+
67
+ function resolveCacheFromContainer(container: AwilixContainer | null | undefined): CacheStrategy | null {
68
+ if (!container) return null
69
+ try {
70
+ const c = container.resolve('cache') as CacheStrategy | undefined
71
+ if (c && typeof c.get === 'function' && typeof c.set === 'function') return c
72
+ } catch {
73
+ return null
74
+ }
75
+ return null
76
+ }
77
+
78
+ export async function invalidateOrganizationScopeCacheForUser(
79
+ container: AwilixContainer,
80
+ userId: string,
81
+ ): Promise<void> {
82
+ const cache = resolveCacheFromContainer(container)
83
+ if (!cache?.deleteByTags) return
84
+ try {
85
+ await cache.deleteByTags([`${ORG_SCOPE_CACHE_KEY_PREFIX}:user:${userId}`])
86
+ } catch (err) {
87
+ console.warn('[org-scope:cache] invalidate user failed', err)
88
+ }
89
+ }
90
+
91
+ export async function invalidateOrganizationScopeCacheForTenant(
92
+ container: AwilixContainer,
93
+ tenantId: string,
94
+ ): Promise<void> {
95
+ const cache = resolveCacheFromContainer(container)
96
+ if (!cache?.deleteByTags) return
97
+ try {
98
+ await cache.deleteByTags([`${ORG_SCOPE_CACHE_KEY_PREFIX}:tenant:${tenantId}`])
99
+ } catch (err) {
100
+ console.warn('[org-scope:cache] invalidate tenant failed', err)
101
+ }
102
+ }
103
+
19
104
  function normalizeOrganizationId(value: unknown): string | null {
20
105
  if (typeof value !== 'string') return null
21
106
  const trimmed = value.trim()
@@ -270,6 +355,31 @@ export async function resolveOrganizationScopeForRequest({
270
355
  }
271
356
 
272
357
  const rawSelected = selectedId !== undefined ? selectedId : (request ? getSelectedOrganizationFromRequest(request) : null)
358
+ const normalizedSelectedId = typeof rawSelected === 'string' && rawSelected.trim().length > 0
359
+ ? rawSelected.trim()
360
+ : null
361
+
362
+ const userId = typeof auth.sub === 'string' && auth.sub.length > 0 ? auth.sub : null
363
+ const ttlMs = resolveOrgScopeTtlMs()
364
+ const cache = ttlMs > 0 ? resolveCacheFromContainer(container) : null
365
+ const cacheKey = userId
366
+ ? buildOrgScopeCacheKey({
367
+ userId,
368
+ effectiveTenantId,
369
+ selectedOrgId: normalizedSelectedId,
370
+ requestedTenantId: requestedTenantId ?? null,
371
+ })
372
+ : null
373
+
374
+ if (cache && cacheKey && typeof cache.get === 'function') {
375
+ try {
376
+ const cached = await cache.get(cacheKey)
377
+ if (isValidCachedScope(cached)) return cached
378
+ } catch (err) {
379
+ console.warn('[org-scope:cache] read failed', err)
380
+ }
381
+ }
382
+
273
383
  const baseScope = await resolveOrganizationScope({
274
384
  em,
275
385
  rbac,
@@ -278,6 +388,17 @@ export async function resolveOrganizationScopeForRequest({
278
388
  tenantId: effectiveTenantId,
279
389
  })
280
390
 
391
+ if (cache && cacheKey && userId && typeof cache.set === 'function') {
392
+ try {
393
+ await cache.set(cacheKey, baseScope, {
394
+ ttl: ttlMs,
395
+ tags: buildOrgScopeCacheTags({ userId, effectiveTenantId }),
396
+ })
397
+ } catch (err) {
398
+ console.warn('[org-scope:cache] write failed', err)
399
+ }
400
+ }
401
+
281
402
  return baseScope
282
403
  }
283
404
 
@@ -37,7 +37,7 @@ export default function ResourcesResourceTypeEditPage({ params }: { params?: { i
37
37
  { errorMessage: t('resources.resourceTypes.errors.load', 'Failed to load resource types.') },
38
38
  )
39
39
  const item = Array.isArray(payload.items) ? payload.items[0] : null
40
- if (!item) throw new Error('not_found')
40
+ if (!item) throw new Error(t('resources.resourceTypes.errors.notFound', 'Resource type not found.'))
41
41
  if (!cancelled) {
42
42
  const customValues = extractCustomFieldValues(item)
43
43
  setInitialValues({
@@ -53,7 +53,7 @@ export default function EditChannelPage({ params }: { params?: { channelId?: str
53
53
  )
54
54
  const item = Array.isArray(payload.items) ? payload.items[0] : null
55
55
  if (!item) {
56
- throw new Error('not_found')
56
+ throw new Error(t('sales.channels.form.errors.notFound', 'Channel not found.'))
57
57
  }
58
58
  if (!cancelled) {
59
59
  setInitialValues(mapChannelToFormValues(item))
@@ -291,7 +291,7 @@ export function ChannelOfferForm({ channelId: lockedChannelId, offerId, mode }:
291
291
  { errorMessage: t('sales.channels.offers.errors.loadOffer', 'Failed to load offer.') },
292
292
  )
293
293
  const offer = Array.isArray(payload.items) ? payload.items[0] : null
294
- if (!offer) throw new Error('not_found')
294
+ if (!offer) throw new Error(t('sales.channels.offers.errors.notFound', 'Offer not found.'))
295
295
  const values = mapOfferToFormValues(offer, lockedChannelId)
296
296
  const pricePayload = await readApiResultOrThrow<PriceResponse>(
297
297
  `/api/catalog/prices?offerId=${encodeURIComponent(offer.id as string)}&pageSize=${MAX_LIST_PAGE_SIZE}`,
@@ -10,6 +10,7 @@ import { Button } from '@open-mercato/ui/primitives/button'
10
10
  import { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'
11
11
  import { apiFetch } from '@open-mercato/ui/backend/utils/api'
12
12
  import { readJsonSafe } from '@open-mercato/ui/backend/utils/serverErrors'
13
+ import { formatWorkflowValidationError } from '../../../lib/format-validation-error'
13
14
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
14
15
  import { useT } from '@open-mercato/shared/lib/i18n/context'
15
16
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
@@ -94,8 +95,8 @@ export default function EditWorkflowDefinitionPage() {
94
95
  body: JSON.stringify(payload),
95
96
  })
96
97
  if (!response.ok) {
97
- const errorBody = await readJsonSafe<{ error?: string }>(response, null)
98
- throw new Error(errorBody?.error || t('workflows.errors.updateFailed'))
98
+ const errorBody = await readJsonSafe<{ error?: string; details?: Array<{ path?: Array<string | number>; message?: string }> }>(response, null)
99
+ throw new Error(formatWorkflowValidationError(errorBody, t('workflows.errors.updateFailed')))
99
100
  }
100
101
  return response
101
102
  },