@open-mercato/core 0.6.7-develop.6726.1.983ae8a07e → 0.6.7-develop.6749.1.6b54c56dfe

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 (191) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/resources_resource/index.js +2 -0
  3. package/dist/generated/entities/resources_resource/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/helpers/integration/salesFixtures.js +31 -2
  7. package/dist/helpers/integration/salesFixtures.js.map +2 -2
  8. package/dist/helpers/integration/salesUi.js +5 -0
  9. package/dist/helpers/integration/salesUi.js.map +2 -2
  10. package/dist/modules/auth/api/profile/route.js +8 -2
  11. package/dist/modules/auth/api/profile/route.js.map +2 -2
  12. package/dist/modules/auth/backend/auth/profile/page.js.map +2 -2
  13. package/dist/modules/auth/backend/profile/change-password/page.js.map +2 -2
  14. package/dist/modules/communication_channels/data/enrichers.js +4 -4
  15. package/dist/modules/communication_channels/data/enrichers.js.map +2 -2
  16. package/dist/modules/customers/analytics.js +1 -0
  17. package/dist/modules/customers/analytics.js.map +2 -2
  18. package/dist/modules/customers/components/detail/DealForm.js +2 -1
  19. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  20. package/dist/modules/customers/components/detail/DealsSection.js +1 -1
  21. package/dist/modules/customers/components/detail/DealsSection.js.map +2 -2
  22. package/dist/modules/customers/data/validators.js +3 -1
  23. package/dist/modules/customers/data/validators.js.map +2 -2
  24. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js +52 -2
  25. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +2 -2
  26. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +42 -26
  27. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +2 -2
  28. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js +2 -2
  29. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js.map +2 -2
  30. package/dist/modules/data_sync/lib/queue-policy.js +15 -0
  31. package/dist/modules/data_sync/lib/queue-policy.js.map +7 -0
  32. package/dist/modules/data_sync/lib/queue.js +16 -1
  33. package/dist/modules/data_sync/lib/queue.js.map +2 -2
  34. package/dist/modules/data_sync/lib/start-run.js +2 -1
  35. package/dist/modules/data_sync/lib/start-run.js.map +2 -2
  36. package/dist/modules/data_sync/lib/sync-engine.js +35 -2
  37. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  38. package/dist/modules/data_sync/lib/sync-run-service.js +70 -2
  39. package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
  40. package/dist/modules/data_sync/workers/sync-export.js +9 -2
  41. package/dist/modules/data_sync/workers/sync-export.js.map +2 -2
  42. package/dist/modules/data_sync/workers/sync-import.js +9 -2
  43. package/dist/modules/data_sync/workers/sync-import.js.map +2 -2
  44. package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js +34 -12
  45. package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js.map +2 -2
  46. package/dist/modules/dictionaries/api/openapi.js +15 -2
  47. package/dist/modules/dictionaries/api/openapi.js.map +2 -2
  48. package/dist/modules/dictionaries/components/hooks/useDictionaryEntries.js +30 -29
  49. package/dist/modules/dictionaries/components/hooks/useDictionaryEntries.js.map +2 -2
  50. package/dist/modules/dictionaries/data/validators.js +7 -0
  51. package/dist/modules/dictionaries/data/validators.js.map +2 -2
  52. package/dist/modules/dictionaries/lib/clientEntries.js +4 -3
  53. package/dist/modules/dictionaries/lib/clientEntries.js.map +2 -2
  54. package/dist/modules/dictionaries/lib/fetchAllEntries.js +43 -0
  55. package/dist/modules/dictionaries/lib/fetchAllEntries.js.map +7 -0
  56. package/dist/modules/entities/api/definitions.js +51 -3
  57. package/dist/modules/entities/api/definitions.js.map +2 -2
  58. package/dist/modules/entities/api/entities.js +21 -2
  59. package/dist/modules/entities/api/entities.js.map +2 -2
  60. package/dist/modules/entities/api/records.js +3 -24
  61. package/dist/modules/entities/api/records.js.map +2 -2
  62. package/dist/modules/entities/api/sidebar-entities.js +34 -12
  63. package/dist/modules/entities/api/sidebar-entities.js.map +2 -2
  64. package/dist/modules/entities/lib/entityAcl.js +46 -0
  65. package/dist/modules/entities/lib/entityAcl.js.map +2 -2
  66. package/dist/modules/messages/api/route.js +2 -5
  67. package/dist/modules/messages/api/route.js.map +2 -2
  68. package/dist/modules/messages/lib/participantScope.js +15 -0
  69. package/dist/modules/messages/lib/participantScope.js.map +7 -0
  70. package/dist/modules/notifications/api/route.js +3 -1
  71. package/dist/modules/notifications/api/route.js.map +2 -2
  72. package/dist/modules/notifications/api/unread-count/route.js +25 -6
  73. package/dist/modules/notifications/api/unread-count/route.js.map +2 -2
  74. package/dist/modules/notifications/lib/notificationScope.js +26 -0
  75. package/dist/modules/notifications/lib/notificationScope.js.map +7 -0
  76. package/dist/modules/notifications/lib/notificationService.js +7 -3
  77. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  78. package/dist/modules/notifications/lib/routeHelpers.js +16 -2
  79. package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
  80. package/dist/modules/planner/components/unavailabilityReasons.js +3 -3
  81. package/dist/modules/planner/components/unavailabilityReasons.js.map +2 -2
  82. package/dist/modules/progress/lib/progressService.js +2 -0
  83. package/dist/modules/progress/lib/progressService.js.map +2 -2
  84. package/dist/modules/progress/lib/progressServiceImpl.js +322 -104
  85. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  86. package/dist/modules/resources/api/resources.js +2 -0
  87. package/dist/modules/resources/api/resources.js.map +2 -2
  88. package/dist/modules/resources/backend/resources/resources/[id]/page.js +3 -2
  89. package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
  90. package/dist/modules/resources/commands/resources.js +9 -0
  91. package/dist/modules/resources/commands/resources.js.map +2 -2
  92. package/dist/modules/resources/components/detail/dictionaries.js +3 -3
  93. package/dist/modules/resources/components/detail/dictionaries.js.map +2 -2
  94. package/dist/modules/resources/data/entities.js +3 -0
  95. package/dist/modules/resources/data/entities.js.map +2 -2
  96. package/dist/modules/resources/data/validators.js +6 -2
  97. package/dist/modules/resources/data/validators.js.map +2 -2
  98. package/dist/modules/resources/lib/seeds.js +16 -9
  99. package/dist/modules/resources/lib/seeds.js.map +2 -2
  100. package/dist/modules/resources/migrations/Migration20260608231000.js +13 -0
  101. package/dist/modules/resources/migrations/Migration20260608231000.js.map +7 -0
  102. package/dist/modules/sales/backend/sales/documents/create/page.js +11 -14
  103. package/dist/modules/sales/backend/sales/documents/create/page.js.map +2 -2
  104. package/dist/modules/sales/commands/documents.js +8 -0
  105. package/dist/modules/sales/commands/documents.js.map +2 -2
  106. package/dist/modules/sales/components/documents/ItemsSection.js +18 -9
  107. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  108. package/dist/modules/sales/components/documents/LineItemDialog.js +12 -5
  109. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  110. package/dist/modules/sales/components/documents/SalesDocumentForm.js +48 -2
  111. package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
  112. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js +167 -0
  113. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js.map +7 -0
  114. package/dist/modules/sales/data/validators.js +5 -1
  115. package/dist/modules/sales/data/validators.js.map +2 -2
  116. package/dist/modules/staff/components/detail/dictionaries.js +3 -3
  117. package/dist/modules/staff/components/detail/dictionaries.js.map +2 -2
  118. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +11 -4
  119. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
  120. package/generated/entities/resources_resource/index.ts +1 -0
  121. package/generated/entity-fields-registry.ts +1 -0
  122. package/package.json +7 -7
  123. package/src/helpers/integration/salesFixtures.ts +34 -2
  124. package/src/helpers/integration/salesUi.ts +8 -0
  125. package/src/modules/auth/api/profile/route.ts +8 -2
  126. package/src/modules/auth/backend/auth/profile/page.tsx +1 -0
  127. package/src/modules/auth/backend/profile/change-password/page.tsx +1 -0
  128. package/src/modules/communication_channels/data/enrichers.ts +6 -26
  129. package/src/modules/customers/analytics.ts +1 -0
  130. package/src/modules/customers/components/detail/DealForm.tsx +5 -1
  131. package/src/modules/customers/components/detail/DealsSection.tsx +1 -1
  132. package/src/modules/customers/data/validators.ts +5 -1
  133. package/src/modules/dashboards/i18n/de.json +3 -0
  134. package/src/modules/dashboards/i18n/en.json +3 -0
  135. package/src/modules/dashboards/i18n/es.json +3 -0
  136. package/src/modules/dashboards/i18n/pl.json +3 -0
  137. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +86 -0
  138. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +33 -18
  139. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.ts +2 -2
  140. package/src/modules/data_sync/lib/adapter.ts +18 -0
  141. package/src/modules/data_sync/lib/queue-policy.ts +30 -0
  142. package/src/modules/data_sync/lib/queue.ts +19 -1
  143. package/src/modules/data_sync/lib/start-run.ts +2 -1
  144. package/src/modules/data_sync/lib/sync-engine.ts +46 -0
  145. package/src/modules/data_sync/lib/sync-run-service.ts +82 -1
  146. package/src/modules/data_sync/workers/sync-export.ts +8 -1
  147. package/src/modules/data_sync/workers/sync-import.ts +8 -1
  148. package/src/modules/dictionaries/api/[dictionaryId]/entries/route.ts +39 -11
  149. package/src/modules/dictionaries/api/openapi.ts +19 -0
  150. package/src/modules/dictionaries/components/hooks/useDictionaryEntries.ts +35 -36
  151. package/src/modules/dictionaries/data/validators.ts +14 -0
  152. package/src/modules/dictionaries/lib/clientEntries.ts +4 -3
  153. package/src/modules/dictionaries/lib/fetchAllEntries.ts +72 -0
  154. package/src/modules/entities/api/definitions.ts +51 -3
  155. package/src/modules/entities/api/entities.ts +30 -2
  156. package/src/modules/entities/api/records.ts +3 -27
  157. package/src/modules/entities/api/sidebar-entities.ts +43 -14
  158. package/src/modules/entities/lib/entityAcl.ts +58 -0
  159. package/src/modules/messages/api/route.ts +4 -5
  160. package/src/modules/messages/lib/participantScope.ts +64 -0
  161. package/src/modules/notifications/api/route.ts +2 -0
  162. package/src/modules/notifications/api/unread-count/route.ts +42 -5
  163. package/src/modules/notifications/lib/notificationScope.ts +40 -0
  164. package/src/modules/notifications/lib/notificationService.ts +5 -0
  165. package/src/modules/notifications/lib/routeHelpers.ts +19 -2
  166. package/src/modules/planner/components/unavailabilityReasons.ts +3 -3
  167. package/src/modules/progress/AGENTS.md +11 -1
  168. package/src/modules/progress/lib/progressService.ts +1 -0
  169. package/src/modules/progress/lib/progressServiceImpl.ts +385 -117
  170. package/src/modules/resources/api/resources.ts +2 -0
  171. package/src/modules/resources/backend/resources/resources/[id]/page.tsx +9 -3
  172. package/src/modules/resources/commands/resources.ts +10 -0
  173. package/src/modules/resources/components/detail/dictionaries.ts +3 -3
  174. package/src/modules/resources/data/entities.ts +3 -0
  175. package/src/modules/resources/data/validators.ts +5 -0
  176. package/src/modules/resources/lib/seeds.ts +16 -9
  177. package/src/modules/resources/migrations/.snapshot-open-mercato.json +17 -1
  178. package/src/modules/resources/migrations/Migration20260608231000.ts +13 -0
  179. package/src/modules/sales/backend/sales/documents/create/page.tsx +2 -4
  180. package/src/modules/sales/commands/documents.ts +8 -0
  181. package/src/modules/sales/components/documents/ItemsSection.tsx +19 -10
  182. package/src/modules/sales/components/documents/LineItemDialog.tsx +13 -5
  183. package/src/modules/sales/components/documents/SalesDocumentForm.tsx +48 -6
  184. package/src/modules/sales/components/documents/SalesOrderDraftLines.tsx +198 -0
  185. package/src/modules/sales/data/validators.ts +7 -1
  186. package/src/modules/sales/i18n/de.json +2 -0
  187. package/src/modules/sales/i18n/en.json +2 -0
  188. package/src/modules/sales/i18n/es.json +2 -0
  189. package/src/modules/sales/i18n/pl.json +2 -0
  190. package/src/modules/staff/components/detail/dictionaries.ts +3 -3
  191. package/src/modules/workflows/widgets/injection/order-approval/widget.client.tsx +12 -5
@@ -3,7 +3,11 @@ import { z } from 'zod'
3
3
  import { runWithCacheTenant } from '@open-mercato/cache'
4
4
  import { Dictionary, DictionaryEntry } from '@open-mercato/core/modules/dictionaries/data/entities'
5
5
  import { resolveDictionariesRouteContext, resolveDictionaryActorId } from '@open-mercato/core/modules/dictionaries/api/context'
6
- import { createDictionaryEntrySchema } from '@open-mercato/core/modules/dictionaries/data/validators'
6
+ import {
7
+ createDictionaryEntrySchema,
8
+ listDictionaryEntriesQuerySchema,
9
+ DICTIONARY_ENTRIES_MAX_LIMIT,
10
+ } from '@open-mercato/core/modules/dictionaries/data/validators'
7
11
  import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
8
12
  import {
9
13
  runCrudMutationGuardAfterSuccess,
@@ -14,6 +18,7 @@ import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/op
14
18
  import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
15
19
  import {
16
20
  createDictionaryEntrySchema as createEntryDocSchema,
21
+ dictionaryEntriesQuerySchema,
17
22
  dictionaryEntryListResponseSchema,
18
23
  dictionaryEntryResponseSchema,
19
24
  dictionaryIdParamsSchema,
@@ -45,8 +50,10 @@ function buildEntriesCacheKey(params: {
45
50
  dictionaryId: string
46
51
  organizationId: string | null
47
52
  sortMode: string
53
+ limit: number
54
+ offset: number
48
55
  }): string {
49
- return `dictionaries:entries:${params.dictionaryId}:org=${params.organizationId ?? 'null'}:sort=${params.sortMode}`
56
+ return `dictionaries:entries:${params.dictionaryId}:org=${params.organizationId ?? 'null'}:sort=${params.sortMode}:limit=${params.limit}:offset=${params.offset}`
50
57
  }
51
58
 
52
59
  async function loadDictionary(
@@ -93,6 +100,11 @@ export async function GET(req: Request, ctx: { params?: { dictionaryId?: string
93
100
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
94
101
  }
95
102
  const { dictionaryId } = paramsSchema.parse({ dictionaryId: ctx.params?.dictionaryId })
103
+ const requestUrl = new URL(req.url)
104
+ const { limit, offset } = listDictionaryEntriesQuerySchema.parse({
105
+ limit: requestUrl.searchParams.get('limit') ?? undefined,
106
+ offset: requestUrl.searchParams.get('offset') ?? undefined,
107
+ })
96
108
  const dictionary = await loadDictionary(context, dictionaryId, { allowInherited: true })
97
109
  const dictionaryTenantId = dictionary.tenantId
98
110
  const dictionaryOrgId = dictionary.organizationId ?? null
@@ -102,9 +114,12 @@ export async function GET(req: Request, ctx: { params?: { dictionaryId?: string
102
114
  // decrypted+sorted options payload. The entry writes flow through the
103
115
  // command bus (resourceKind dictionaries.entry), which already flushes the
104
116
  // matching collection tag post-commit — no new invalidation wiring needed.
105
- const cache = isCrudCacheEnabled() ? resolveCrudCache(context.container) : null
117
+ // Only the default page is cached: keying the cache on caller-supplied
118
+ // pagination would let any reader mint unbounded distinct cache entries.
119
+ const isDefaultPage = limit === DICTIONARY_ENTRIES_MAX_LIMIT && offset === 0
120
+ const cache = isCrudCacheEnabled() && isDefaultPage ? resolveCrudCache(context.container) : null
106
121
  const cacheKey = cache
107
- ? buildEntriesCacheKey({ dictionaryId, organizationId: dictionaryOrgId, sortMode })
122
+ ? buildEntriesCacheKey({ dictionaryId, organizationId: dictionaryOrgId, sortMode, limit, offset })
108
123
  : null
109
124
 
110
125
  if (cache && cacheKey) {
@@ -114,15 +129,19 @@ export async function GET(req: Request, ctx: { params?: { dictionaryId?: string
114
129
  }
115
130
  }
116
131
 
132
+ const entriesFilter = {
133
+ dictionary,
134
+ organizationId: dictionary.organizationId,
135
+ tenantId: dictionary.tenantId,
136
+ }
137
+ const total = await context.em.count(DictionaryEntry, entriesFilter)
138
+ // Bound the decrypt+sort work per request: page membership is decided by a
139
+ // stable database ordering, and the dictionary's sort mode orders the page.
117
140
  const entries = await findWithDecryption(
118
141
  context.em,
119
142
  DictionaryEntry,
120
- {
121
- dictionary,
122
- organizationId: dictionary.organizationId,
123
- tenantId: dictionary.tenantId,
124
- },
125
- {},
143
+ entriesFilter,
144
+ { orderBy: { position: 'asc', id: 'asc' }, limit, offset },
126
145
  { tenantId: dictionary.tenantId, organizationId: dictionary.organizationId },
127
146
  )
128
147
  const sortedEntries = sortDictionaryEntries(entries, sortMode)
@@ -139,6 +158,14 @@ export async function GET(req: Request, ctx: { params?: { dictionaryId?: string
139
158
  createdAt: entry.createdAt,
140
159
  updatedAt: entry.updatedAt,
141
160
  })),
161
+ total,
162
+ limit,
163
+ offset,
164
+ hasMore: offset + sortedEntries.length < total,
165
+ // Page membership comes from the database ordering, while the page itself
166
+ // is ordered in memory. A client assembling several pages has to re-apply
167
+ // the same comparator, so it needs the mode that produced this ordering.
168
+ sortMode,
142
169
  }
143
170
 
144
171
  if (cache && cacheKey) {
@@ -265,8 +292,9 @@ export async function POST(req: Request, ctx: { params?: { dictionaryId?: string
265
292
 
266
293
  const dictionaryEntriesGetDoc: OpenApiMethodDoc = {
267
294
  summary: 'List dictionary entries',
268
- description: 'Returns entries for the specified dictionary ordered by its configured entry sort mode.',
295
+ description: `Returns entries for the specified dictionary ordered by its configured entry sort mode. The number of entries returned per request is capped at ${DICTIONARY_ENTRIES_MAX_LIMIT}; use \`limit\` and \`offset\` with the \`total\`/\`hasMore\` response fields to page through larger dictionaries.`,
269
296
  tags: [dictionariesTag],
297
+ query: dictionaryEntriesQuerySchema,
270
298
  responses: [
271
299
  { status: 200, description: 'Dictionary entries.', schema: dictionaryEntryListResponseSchema },
272
300
  ],
@@ -5,6 +5,7 @@ import {
5
5
  updateDictionaryEntrySchema,
6
6
  reorderDictionaryEntriesSchema,
7
7
  setDefaultDictionaryEntrySchema,
8
+ DICTIONARY_ENTRIES_MAX_LIMIT,
8
9
  } from '@open-mercato/core/modules/dictionaries/data/validators'
9
10
  import { dictionaryEntrySortModeSchema } from '@open-mercato/core/modules/dictionaries/lib/entrySort'
10
11
 
@@ -57,6 +58,24 @@ export const dictionaryEntrySchema = z.object({
57
58
 
58
59
  export const dictionaryEntryListResponseSchema = z.object({
59
60
  items: z.array(dictionaryEntrySchema),
61
+ total: z.number().int(),
62
+ limit: z.number().int(),
63
+ offset: z.number().int(),
64
+ hasMore: z.boolean(),
65
+ sortMode: dictionaryEntrySortModeSchema.describe(
66
+ 'Sort mode that ordered this page. Clients assembling multiple pages must re-apply it across the combined set.',
67
+ ),
68
+ })
69
+
70
+ export const dictionaryEntriesQuerySchema = z.object({
71
+ limit: z.coerce
72
+ .number()
73
+ .int()
74
+ .positive()
75
+ .max(DICTIONARY_ENTRIES_MAX_LIMIT)
76
+ .optional()
77
+ .describe(`Maximum entries to return. Defaults to and is capped at ${DICTIONARY_ENTRIES_MAX_LIMIT}.`),
78
+ offset: z.coerce.number().int().min(0).optional().describe('Number of entries to skip. Defaults to 0.'),
60
79
  })
61
80
 
62
81
  export const dictionaryEntryResponseSchema = dictionaryEntrySchema
@@ -1,6 +1,5 @@
1
1
  "use client"
2
2
 
3
- import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
4
3
  import { useQuery, type QueryClient, type UseQueryResult } from '@tanstack/react-query'
5
4
  import {
6
5
  createDictionaryMap,
@@ -8,6 +7,7 @@ import {
8
7
  type DictionaryDisplayEntry,
9
8
  type DictionaryMap,
10
9
  } from '../dictionaryAppearance'
10
+ import { fetchAllDictionaryEntries } from '../../lib/fetchAllEntries'
11
11
 
12
12
  export type DictionaryEntryRecord = DictionaryDisplayEntry & {
13
13
  id: string
@@ -28,47 +28,46 @@ const BASE_DICTIONARY_ENTRIES_KEY = ['dictionaries', 'entries'] as const
28
28
  export const dictionaryEntriesQueryKey = (dictionaryId: string, scopeVersion = 0) =>
29
29
  [...BASE_DICTIONARY_ENTRIES_KEY, dictionaryId, `scope:${scopeVersion}`] as const
30
30
 
31
+ function parseDictionaryEntryItems(items: unknown[]): DictionaryEntryRecord[] {
32
+ const parsed: DictionaryEntryRecord[] = []
33
+ for (const item of items) {
34
+ if (!item || typeof item !== 'object') continue
35
+ const data = item as Record<string, unknown>
36
+ const id = typeof data.id === 'string' ? data.id : ''
37
+ const value = typeof data.value === 'string' ? data.value.trim() : ''
38
+ if (!id || !value) continue
39
+ const label =
40
+ typeof data.label === 'string' && data.label.trim().length ? data.label.trim() : value
41
+ const color =
42
+ typeof data.color === 'string' && /^#([0-9a-fA-F]{6})$/.test(data.color)
43
+ ? `#${data.color.slice(1).toLowerCase()}`
44
+ : null
45
+ const icon = typeof data.icon === 'string' && data.icon.trim().length ? data.icon.trim() : null
46
+ const createdAt = typeof data.createdAt === 'string' ? data.createdAt : null
47
+ const updatedAt = typeof data.updatedAt === 'string' ? data.updatedAt : null
48
+ parsed.push({
49
+ id,
50
+ value,
51
+ label,
52
+ color,
53
+ icon,
54
+ createdAt,
55
+ updatedAt,
56
+ })
57
+ }
58
+ return parsed
59
+ }
60
+
31
61
  export const dictionaryEntriesQueryOptions = (dictionaryId: string, scopeVersion = 0) => ({
32
62
  queryKey: dictionaryEntriesQueryKey(dictionaryId, scopeVersion),
33
63
  staleTime: DICTIONARY_ENTRIES_STALE_TIME,
34
64
  gcTime: DICTIONARY_ENTRIES_STALE_TIME,
35
65
  queryFn: async (): Promise<DictionaryEntriesQueryData> => {
36
- const call = await apiCall<Record<string, unknown>>(`/api/dictionaries/${dictionaryId}/entries`)
37
- const payload = call.result ?? {}
38
- if (!call.ok) {
39
- const message =
40
- typeof payload.error === 'string'
41
- ? payload.error
42
- : 'Failed to load dictionary entries.'
43
- throw new Error(message)
44
- }
45
- const items = Array.isArray(payload.items) ? payload.items : []
46
- const parsed: DictionaryEntryRecord[] = []
47
- for (const item of items) {
48
- if (!item || typeof item !== 'object') continue
49
- const data = item as Record<string, unknown>
50
- const id = typeof data.id === 'string' ? data.id : ''
51
- const value = typeof data.value === 'string' ? data.value.trim() : ''
52
- if (!id || !value) continue
53
- const label =
54
- typeof data.label === 'string' && data.label.trim().length ? data.label.trim() : value
55
- const color =
56
- typeof data.color === 'string' && /^#([0-9a-fA-F]{6})$/.test(data.color)
57
- ? `#${data.color.slice(1).toLowerCase()}`
58
- : null
59
- const icon = typeof data.icon === 'string' && data.icon.trim().length ? data.icon.trim() : null
60
- const createdAt = typeof data.createdAt === 'string' ? data.createdAt : null
61
- const updatedAt = typeof data.updatedAt === 'string' ? data.updatedAt : null
62
- parsed.push({
63
- id,
64
- value,
65
- label,
66
- color,
67
- icon,
68
- createdAt,
69
- updatedAt,
70
- })
66
+ const pages = await fetchAllDictionaryEntries(dictionaryId)
67
+ if (!pages.ok) {
68
+ throw new Error(pages.errorMessage ?? 'Failed to load dictionary entries.')
71
69
  }
70
+ const parsed = parseDictionaryEntryItems(pages.items)
72
71
  const normalized = normalizeDictionaryEntries(
73
72
  parsed.map(({ value, label, color, icon }) => ({ value, label, color, icon })),
74
73
  { sort: false },
@@ -26,6 +26,20 @@ export const upsertDictionarySchema = z.object({
26
26
 
27
27
  export type UpsertDictionaryInput = z.infer<typeof upsertDictionarySchema>
28
28
 
29
+ export const DICTIONARY_ENTRIES_MAX_LIMIT = 500
30
+
31
+ export const listDictionaryEntriesQuerySchema = z.object({
32
+ limit: z.coerce
33
+ .number()
34
+ .int()
35
+ .positive()
36
+ .max(DICTIONARY_ENTRIES_MAX_LIMIT)
37
+ .catch(DICTIONARY_ENTRIES_MAX_LIMIT),
38
+ offset: z.coerce.number().int().min(0).catch(0),
39
+ })
40
+
41
+ export type ListDictionaryEntriesQuery = z.infer<typeof listDictionaryEntriesQuerySchema>
42
+
29
43
  export const createDictionaryEntrySchema = z.object({
30
44
  value: z.string().trim().min(1).max(150),
31
45
  label: z.string().trim().min(1).max(150).optional(),
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
4
+ import { fetchAllDictionaryEntries } from './fetchAllEntries'
4
5
 
5
6
  type DictionarySummary = {
6
7
  id: string
@@ -59,8 +60,8 @@ export async function loadDictionaryEntriesByKey(key: string): Promise<Dictionar
59
60
  const dictionary = normalizeDictionaryList(dictionariesCall.result?.items).find((item) => item.key === key)
60
61
  if (!dictionary) return []
61
62
 
62
- const entriesCall = await apiCall<{ items?: unknown[] }>(`/api/dictionaries/${dictionary.id}/entries`)
63
- if (!entriesCall.ok) return []
63
+ const entries = await fetchAllDictionaryEntries(dictionary.id)
64
+ if (!entries.ok) return []
64
65
 
65
- return normalizeDictionaryEntries(entriesCall.result?.items)
66
+ return normalizeDictionaryEntries(entries.items)
66
67
  }
@@ -0,0 +1,72 @@
1
+ "use client"
2
+
3
+ import { fetchAllOffsetPages } from '@open-mercato/ui/backend/utils/fetchAllPages'
4
+ import {
5
+ resolveDictionaryEntrySortMode,
6
+ sortDictionaryEntries,
7
+ type DictionaryEntrySortItem,
8
+ } from './entrySort'
9
+
10
+ export type DictionaryEntryItem = Record<string, unknown>
11
+
12
+ export type DictionaryEntriesResult = {
13
+ ok: boolean
14
+ items: DictionaryEntryItem[]
15
+ pageCount: number
16
+ errorMessage: string | null
17
+ }
18
+
19
+ type DictionaryEntrySortView = DictionaryEntrySortItem & { entry: DictionaryEntryItem }
20
+
21
+ function toRecords(items: unknown[]): DictionaryEntryItem[] {
22
+ return items.filter((item): item is DictionaryEntryItem => !!item && typeof item === 'object')
23
+ }
24
+
25
+ function toSortView(entry: DictionaryEntryItem): DictionaryEntrySortView {
26
+ const value = typeof entry.value === 'string' ? entry.value.trim() : ''
27
+ const label = typeof entry.label === 'string' ? entry.label.trim() : ''
28
+ return {
29
+ entry,
30
+ id: typeof entry.id === 'string' ? entry.id : null,
31
+ value: value.length ? value : null,
32
+ label: label.length ? label : null,
33
+ createdAt: typeof entry.createdAt === 'string' ? entry.createdAt : null,
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Load every entry of a dictionary, walking the entries route's page cap.
39
+ *
40
+ * The route bounds each response, so any consumer that needs the full option
41
+ * list must page — reading `items` from a single request silently drops
42
+ * everything past the ceiling and makes those values unselectable.
43
+ */
44
+ export async function fetchAllDictionaryEntries(
45
+ dictionaryId: string,
46
+ ): Promise<DictionaryEntriesResult> {
47
+ const pages = await fetchAllOffsetPages(`/api/dictionaries/${dictionaryId}/entries`)
48
+ const records = toRecords(pages.items)
49
+
50
+ if (!pages.ok) {
51
+ const error = pages.errorPayload?.error
52
+ return {
53
+ ok: false,
54
+ items: records,
55
+ pageCount: pages.pageCount,
56
+ errorMessage: typeof error === 'string' ? error : null,
57
+ }
58
+ }
59
+
60
+ // The route bounds each response and orders it in memory, so entries
61
+ // assembled from several pages are only sorted within each page. Re-apply the
62
+ // mode that produced them across the whole set. A single-page dictionary —
63
+ // every dictionary at or below the route ceiling — keeps the server ordering
64
+ // byte-for-byte.
65
+ if (pages.pageCount <= 1) {
66
+ return { ok: true, items: records, pageCount: pages.pageCount, errorMessage: null }
67
+ }
68
+
69
+ const sortMode = resolveDictionaryEntrySortMode(pages.firstPayload.sortMode)
70
+ const sorted = sortDictionaryEntries(records.map(toSortView), sortMode).map((view) => view.entry)
71
+ return { ok: true, items: sorted, pageCount: pages.pageCount, errorMessage: null }
72
+ }
@@ -14,6 +14,7 @@ import {
14
14
  } from './definitions.cache'
15
15
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
16
16
  import { filterSelectableSystemEntityIds, isSystemEntitySelectable } from '@open-mercato/shared/lib/entities/system-entities'
17
+ import { isOrmBackedSystemEntityId } from '@open-mercato/shared/lib/data/engine'
17
18
  import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
18
19
  import { loadEntityFieldsetConfigs, CustomFieldsetDefinition } from '../lib/fieldsets'
19
20
  import { installCustomEntitiesFromModules } from '../lib/install-from-ce'
@@ -24,6 +25,12 @@ import {
24
25
  beginEntitiesMutationGuard,
25
26
  FIELD_DEFINITION_RESOURCE_KIND,
26
27
  } from './definitions.mutation-guard'
28
+ import {
29
+ canReadAllEntityMetadata,
30
+ canReadEntityMetadata,
31
+ getDeclaredCustomEntityRestriction,
32
+ resolveEntityAclRequirement,
33
+ } from '../lib/entityAcl'
27
34
  import {
28
35
  createExactDefinitionWhere,
29
36
  createScopedDefinitionTombstone,
@@ -226,8 +233,8 @@ export async function GET(req: Request) {
226
233
  const auth = await getAuthFromRequest(req)
227
234
  if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
228
235
 
229
- const entityIds = filterSelectableSystemEntityIds(requestedEntityIds)
230
- if (!entityIds.length) {
236
+ const selectableEntityIds = filterSelectableSystemEntityIds(requestedEntityIds)
237
+ if (!selectableEntityIds.length) {
231
238
  return NextResponse.json({ items: [] })
232
239
  }
233
240
 
@@ -241,6 +248,48 @@ export async function GET(req: Request) {
241
248
  const organizationId = definitionScope.organizationId
242
249
  const { resolve } = container
243
250
  const em = resolve('em') as any
251
+ const rbac = resolve('rbacService') as RbacService
252
+ const acl = await rbac.loadAcl(auth.sub ?? '', {
253
+ tenantId,
254
+ organizationId,
255
+ })
256
+ const designerCanRead = canReadAllEntityMetadata(acl)
257
+ const customEntityRestrictions = new Map<string, boolean>()
258
+ for (const entityId of selectableEntityIds) {
259
+ if (isOrmBackedSystemEntityId(em, entityId)) continue
260
+ const declaredRestriction = getDeclaredCustomEntityRestriction(entityId)
261
+ if (declaredRestriction !== undefined) customEntityRestrictions.set(entityId, declaredRestriction)
262
+ }
263
+ if (!designerCanRead) {
264
+ const unresolvedEntityIds = selectableEntityIds.filter(
265
+ (entityId) => !resolveEntityAclRequirement(entityId) && !customEntityRestrictions.has(entityId),
266
+ )
267
+ if (unresolvedEntityIds.length) {
268
+ const registrations = await em.find(CustomEntity as any, {
269
+ entityId: { $in: unresolvedEntityIds },
270
+ isActive: true,
271
+ $and: [
272
+ { $or: [{ tenantId }, { tenantId: null }] },
273
+ { $or: [{ organizationId }, { organizationId: null }] },
274
+ ],
275
+ } as any)
276
+ for (const registration of registrations as any[]) {
277
+ const entityId = String(registration.entityId)
278
+ if (!isOrmBackedSystemEntityId(em, entityId)) {
279
+ customEntityRestrictions.set(entityId, registration.accessRestricted === true)
280
+ }
281
+ }
282
+ }
283
+ }
284
+ const entityIds = selectableEntityIds.filter((entityId) => canReadEntityMetadata({
285
+ entityId,
286
+ isCustomEntity: customEntityRestrictions.has(entityId),
287
+ isRestricted: customEntityRestrictions.get(entityId) === true,
288
+ acl,
289
+ }))
290
+ if (!entityIds.length) {
291
+ return NextResponse.json({ items: [], fieldsetsByEntity: {}, entitySettings: {} })
292
+ }
244
293
  let cache: CacheStrategy | undefined
245
294
  try {
246
295
  cache = resolve('cache') as CacheStrategy
@@ -249,7 +298,6 @@ export async function GET(req: Request) {
249
298
  let canManageDefinitions = false
250
299
  if (typeof auth.sub === 'string' && auth.sub.length > 0) {
251
300
  try {
252
- const rbac = resolve('rbacService') as RbacService
253
301
  canManageDefinitions = await rbac.userHasAllFeatures(auth.sub, ['entities.definitions.manage'], {
254
302
  tenantId,
255
303
  organizationId,
@@ -10,6 +10,8 @@ import { isSystemEntitySelectable } from '@open-mercato/shared/lib/entities/syst
10
10
  import { SYSTEM_ENTITY_RECORDS_BLOCKED_CODE, isOrmBackedSystemEntityId } from '@open-mercato/shared/lib/data/engine'
11
11
  import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
12
12
  import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
13
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
14
+ import { canReadEntityMetadata, getDeclaredCustomEntityRestriction } from '../lib/entityAcl'
13
15
  import {
14
16
  beginEntitiesMutationGuard,
15
17
  ENTITY_DEFINITION_RESOURCE_KIND,
@@ -29,6 +31,11 @@ export async function GET(req: Request) {
29
31
 
30
32
  const { resolve } = await createRequestContainer()
31
33
  const em = resolve('em') as any
34
+ const rbac = resolve('rbacService') as RbacService
35
+ const acl = await rbac.loadAcl(auth.sub ?? '', {
36
+ tenantId: auth.tenantId,
37
+ organizationId: auth.orgId ?? null,
38
+ })
32
39
 
33
40
  // Generated entities from code
34
41
  const AllEntities = getEntityIds()
@@ -79,8 +86,27 @@ export async function GET(req: Request) {
79
86
  byId.set(cu.entityId, { ...existing, ...cu, source: existing?.source ?? cu.source })
80
87
  }
81
88
 
89
+ const visibleEntityIds = new Set(
90
+ Array.from(byId.values())
91
+ .filter((item: any) => {
92
+ const isOrmBacked = isOrmBackedSystemEntityId(em, item.entityId)
93
+ const declaredRestriction = isOrmBacked
94
+ ? undefined
95
+ : getDeclaredCustomEntityRestriction(item.entityId)
96
+ return canReadEntityMetadata({
97
+ entityId: item.entityId,
98
+ isCustomEntity: !isOrmBacked
99
+ && (item.source === 'custom' || declaredRestriction !== undefined),
100
+ isRestricted: declaredRestriction ?? item.accessRestricted === true,
101
+ acl,
102
+ })
103
+ })
104
+ .map((item: any) => item.entityId),
105
+ )
106
+ if (!visibleEntityIds.size) return NextResponse.json({ items: [] })
107
+
82
108
  // Count field definitions scoped to current tenant/org (same scoping as custom entities)
83
- const defsWhere: any = { isActive: true }
109
+ const defsWhere: any = { isActive: true, entityId: { $in: Array.from(visibleEntityIds) } }
84
110
  defsWhere.$and = [
85
111
  //{ $or: [ { organizationId: auth.orgId ?? undefined as any }, { organizationId: null } ] }, // the entities and custom fields are defined per tenant
86
112
  { tenantId: auth.tenantId ?? undefined as any },
@@ -99,7 +125,9 @@ export async function GET(req: Request) {
99
125
  const counts: Record<string, number> = {}
100
126
  for (const [eid, set] of keySets.entries()) counts[eid] = set.size
101
127
 
102
- const items = Array.from(byId.values()).map((it: any) => ({ ...it, count: counts[it.entityId] || 0 }))
128
+ const items = Array.from(byId.values())
129
+ .filter((item: any) => visibleEntityIds.has(item.entityId))
130
+ .map((item: any) => ({ ...item, count: counts[item.entityId] || 0 }))
103
131
  return NextResponse.json({ items })
104
132
  }
105
133
 
@@ -14,35 +14,11 @@ import { CustomFieldValue } from '../data/entities'
14
14
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
15
15
  import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
16
16
  import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
17
- import { getModules } from '@open-mercato/shared/lib/i18n/server'
18
- import { assertEntityAclForRequest } from '../lib/entityAcl'
17
+ import { assertEntityAclForRequest, getDeclaredCustomEntityRestriction } from '../lib/entityAcl'
19
18
  import { createLogger } from '@open-mercato/shared/lib/logger'
20
19
 
21
20
  const logger = createLogger('entities').child({ component: 'records' })
22
21
 
23
- let declaredCustomEntityRestricted: Map<string, boolean> | null = null
24
- function loadDeclaredCustomEntities(): Map<string, boolean> {
25
- if (declaredCustomEntityRestricted === null) {
26
- try {
27
- const mods = getModules() as Array<{ customEntities?: Array<{ id?: string; accessRestricted?: boolean }> }>
28
- const map = new Map<string, boolean>()
29
- for (const mod of mods ?? []) {
30
- for (const spec of mod?.customEntities ?? []) {
31
- if (spec?.id) map.set(spec.id, spec.accessRestricted === true)
32
- }
33
- }
34
- // Cache even when empty so we don't rebuild on every request (and fall back
35
- // to the DB lookup unnecessarily). Only a thrown getModules() leaves it null
36
- // so a genuinely-uninitialized registry is retried.
37
- declaredCustomEntityRestricted = map
38
- } catch {}
39
- }
40
- return declaredCustomEntityRestricted ?? new Map<string, boolean>()
41
- }
42
- function isDeclaredCustomEntity(entityId: string): boolean {
43
- return loadDeclaredCustomEntities().has(entityId)
44
- }
45
-
46
22
  type RecordsEntityScope = { tenantId: string | null; organizationId: string | null }
47
23
 
48
24
  // Resolve the CustomEntity registration that applies to THIS caller, most-specific
@@ -86,8 +62,8 @@ type RecordsEntityClassification = { kind: RecordsEntityKind; restricted: boolea
86
62
  // or an active `custom_entities` registration is authoritative.
87
63
  async function classifyRecordsEntity(em: any, entityId: string, scope: RecordsEntityScope): Promise<RecordsEntityClassification> {
88
64
  if (isOrmBackedSystemEntityId(em, entityId)) return { kind: 'system', restricted: false }
89
- const declared = loadDeclaredCustomEntities()
90
- if (declared.has(entityId)) return { kind: 'custom', restricted: declared.get(entityId) === true }
65
+ const declaredRestriction = getDeclaredCustomEntityRestriction(entityId)
66
+ if (declaredRestriction !== undefined) return { kind: 'custom', restricted: declaredRestriction }
91
67
  try {
92
68
  const { CustomEntity } = await import('../data/entities')
93
69
  // Restriction is decided from the row that applies to THIS caller's scope so
@@ -5,9 +5,11 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { EntityManager } from '@mikro-orm/core'
6
6
  import { CustomEntity } from '@open-mercato/core/modules/entities/data/entities'
7
7
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
8
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
9
+ import { canReadEntityMetadata, getDeclaredCustomEntityRestriction } from '../lib/entityAcl'
8
10
 
9
11
  export const metadata = {
10
- GET: { requireAuth: true },
12
+ GET: { requireAuth: true, requireFeatures: ['entities.records.view'] },
11
13
  }
12
14
 
13
15
  export async function GET(req: Request) {
@@ -17,6 +19,11 @@ export async function GET(req: Request) {
17
19
  const { resolve } = await createRequestContainer()
18
20
  const em = resolve('em') as EntityManager
19
21
  const cache = resolve('cache') as any
22
+ const rbac = resolve('rbacService') as RbacService
23
+ const acl = await rbac.loadAcl(auth.sub ?? '', {
24
+ tenantId: auth.tenantId,
25
+ organizationId: auth.orgId ?? null,
26
+ })
20
27
 
21
28
  const where: any = {
22
29
  isActive: true,
@@ -27,27 +34,49 @@ export async function GET(req: Request) {
27
34
  { $or: [ { tenantId: auth.tenantId ?? undefined as any }, { tenantId: null } ] },
28
35
  ]
29
36
 
30
- // Try cache first to avoid repeated queries on focus refreshes
31
- const cacheKey = `entities:sidebar:${auth.tenantId || 'null'}`
37
+ const cacheKey = `entities:sidebar:v2:${auth.tenantId || 'null'}`
38
+ let candidates: Array<{
39
+ entityId: string
40
+ label: string
41
+ href: string
42
+ accessRestricted: boolean
43
+ }> | null = null
32
44
  try {
33
45
  if (cache) {
34
46
  const cached = await cache.get(cacheKey)
35
- if (cached && Array.isArray(cached.items)) return NextResponse.json(cached)
47
+ if (cached && Array.isArray(cached.candidates)) candidates = cached.candidates
36
48
  }
37
49
  } catch {}
38
50
 
39
- const entities = await em.find(CustomEntity as any, where as any, { orderBy: { label: 'asc' } as any })
40
-
41
- const items = (entities as any[]).map((e) => ({
42
- entityId: e.entityId,
43
- label: e.label,
44
- href: `/backend/entities/user/${encodeURIComponent(e.entityId)}/records`
45
- }))
51
+ if (!candidates) {
52
+ const entities = await em.find(CustomEntity as any, where as any, { orderBy: { label: 'asc' } as any })
53
+ candidates = (entities as any[]).map((entity) => ({
54
+ entityId: entity.entityId,
55
+ label: entity.label,
56
+ href: `/backend/entities/user/${encodeURIComponent(entity.entityId)}/records`,
57
+ accessRestricted: getDeclaredCustomEntityRestriction(entity.entityId)
58
+ ?? entity.accessRestricted === true,
59
+ }))
60
+ try {
61
+ if (cache) {
62
+ await cache.set(cacheKey, { candidates }, { tags: [`nav:entities:${auth.tenantId || 'null'}`] })
63
+ }
64
+ } catch {}
65
+ }
46
66
 
67
+ const items = candidates
68
+ .filter((entity) => canReadEntityMetadata({
69
+ entityId: entity.entityId,
70
+ isCustomEntity: true,
71
+ isRestricted: entity.accessRestricted,
72
+ acl,
73
+ }))
74
+ .map((entity) => ({
75
+ entityId: entity.entityId,
76
+ label: entity.label,
77
+ href: entity.href,
78
+ }))
47
79
  const payload = { items }
48
- try {
49
- if (cache) await cache.set(cacheKey, payload, { tags: [`nav:entities:${auth.tenantId || 'null'}`] })
50
- } catch {}
51
80
  return NextResponse.json(payload)
52
81
  }
53
82