@open-mercato/core 0.6.7-develop.6725.1.07a748cfc2 → 0.6.7-develop.6744.1.0483b18565

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 (153) 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/communication_channels/data/enrichers.js +4 -4
  11. package/dist/modules/communication_channels/data/enrichers.js.map +2 -2
  12. package/dist/modules/customers/analytics.js +1 -0
  13. package/dist/modules/customers/analytics.js.map +2 -2
  14. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +17 -3
  15. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +2 -2
  16. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +7 -1
  17. package/dist/modules/customers/backend/customers/deals/pipeline/page.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/dictionaries/api/[dictionaryId]/entries/route.js +34 -12
  31. package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js.map +2 -2
  32. package/dist/modules/dictionaries/api/openapi.js +15 -2
  33. package/dist/modules/dictionaries/api/openapi.js.map +2 -2
  34. package/dist/modules/dictionaries/components/hooks/useDictionaryEntries.js +30 -29
  35. package/dist/modules/dictionaries/components/hooks/useDictionaryEntries.js.map +2 -2
  36. package/dist/modules/dictionaries/data/validators.js +7 -0
  37. package/dist/modules/dictionaries/data/validators.js.map +2 -2
  38. package/dist/modules/dictionaries/lib/clientEntries.js +4 -3
  39. package/dist/modules/dictionaries/lib/clientEntries.js.map +2 -2
  40. package/dist/modules/dictionaries/lib/fetchAllEntries.js +43 -0
  41. package/dist/modules/dictionaries/lib/fetchAllEntries.js.map +7 -0
  42. package/dist/modules/entities/api/definitions.js +51 -3
  43. package/dist/modules/entities/api/definitions.js.map +2 -2
  44. package/dist/modules/entities/api/entities.js +21 -2
  45. package/dist/modules/entities/api/entities.js.map +2 -2
  46. package/dist/modules/entities/api/records.js +3 -24
  47. package/dist/modules/entities/api/records.js.map +2 -2
  48. package/dist/modules/entities/api/sidebar-entities.js +34 -12
  49. package/dist/modules/entities/api/sidebar-entities.js.map +2 -2
  50. package/dist/modules/entities/lib/entityAcl.js +46 -0
  51. package/dist/modules/entities/lib/entityAcl.js.map +2 -2
  52. package/dist/modules/messages/api/route.js +2 -5
  53. package/dist/modules/messages/api/route.js.map +2 -2
  54. package/dist/modules/messages/lib/participantScope.js +15 -0
  55. package/dist/modules/messages/lib/participantScope.js.map +7 -0
  56. package/dist/modules/planner/components/unavailabilityReasons.js +3 -3
  57. package/dist/modules/planner/components/unavailabilityReasons.js.map +2 -2
  58. package/dist/modules/progress/lib/progressService.js +2 -0
  59. package/dist/modules/progress/lib/progressService.js.map +2 -2
  60. package/dist/modules/progress/lib/progressServiceImpl.js +322 -104
  61. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  62. package/dist/modules/resources/api/resources.js +2 -0
  63. package/dist/modules/resources/api/resources.js.map +2 -2
  64. package/dist/modules/resources/backend/resources/resources/[id]/page.js +3 -2
  65. package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
  66. package/dist/modules/resources/commands/resources.js +9 -0
  67. package/dist/modules/resources/commands/resources.js.map +2 -2
  68. package/dist/modules/resources/components/detail/dictionaries.js +3 -3
  69. package/dist/modules/resources/components/detail/dictionaries.js.map +2 -2
  70. package/dist/modules/resources/data/entities.js +3 -0
  71. package/dist/modules/resources/data/entities.js.map +2 -2
  72. package/dist/modules/resources/data/validators.js +6 -2
  73. package/dist/modules/resources/data/validators.js.map +2 -2
  74. package/dist/modules/resources/lib/seeds.js +16 -9
  75. package/dist/modules/resources/lib/seeds.js.map +2 -2
  76. package/dist/modules/resources/migrations/Migration20260608231000.js +13 -0
  77. package/dist/modules/resources/migrations/Migration20260608231000.js.map +7 -0
  78. package/dist/modules/sales/backend/sales/documents/create/page.js +11 -14
  79. package/dist/modules/sales/backend/sales/documents/create/page.js.map +2 -2
  80. package/dist/modules/sales/commands/documents.js +8 -0
  81. package/dist/modules/sales/commands/documents.js.map +2 -2
  82. package/dist/modules/sales/components/documents/ItemsSection.js +18 -9
  83. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  84. package/dist/modules/sales/components/documents/LineItemDialog.js +12 -5
  85. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  86. package/dist/modules/sales/components/documents/SalesDocumentForm.js +48 -2
  87. package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
  88. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js +167 -0
  89. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js.map +7 -0
  90. package/dist/modules/sales/data/validators.js +5 -1
  91. package/dist/modules/sales/data/validators.js.map +2 -2
  92. package/dist/modules/staff/components/detail/dictionaries.js +3 -3
  93. package/dist/modules/staff/components/detail/dictionaries.js.map +2 -2
  94. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +11 -4
  95. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
  96. package/generated/entities/resources_resource/index.ts +1 -0
  97. package/generated/entity-fields-registry.ts +1 -0
  98. package/package.json +7 -7
  99. package/src/helpers/integration/salesFixtures.ts +34 -2
  100. package/src/helpers/integration/salesUi.ts +8 -0
  101. package/src/modules/communication_channels/data/enrichers.ts +6 -26
  102. package/src/modules/customers/analytics.ts +1 -0
  103. package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +35 -3
  104. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +10 -1
  105. package/src/modules/customers/components/detail/DealForm.tsx +5 -1
  106. package/src/modules/customers/components/detail/DealsSection.tsx +1 -1
  107. package/src/modules/customers/data/validators.ts +5 -1
  108. package/src/modules/dashboards/i18n/de.json +3 -0
  109. package/src/modules/dashboards/i18n/en.json +3 -0
  110. package/src/modules/dashboards/i18n/es.json +3 -0
  111. package/src/modules/dashboards/i18n/pl.json +3 -0
  112. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +86 -0
  113. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +33 -18
  114. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.ts +2 -2
  115. package/src/modules/dictionaries/api/[dictionaryId]/entries/route.ts +39 -11
  116. package/src/modules/dictionaries/api/openapi.ts +19 -0
  117. package/src/modules/dictionaries/components/hooks/useDictionaryEntries.ts +35 -36
  118. package/src/modules/dictionaries/data/validators.ts +14 -0
  119. package/src/modules/dictionaries/lib/clientEntries.ts +4 -3
  120. package/src/modules/dictionaries/lib/fetchAllEntries.ts +72 -0
  121. package/src/modules/entities/api/definitions.ts +51 -3
  122. package/src/modules/entities/api/entities.ts +30 -2
  123. package/src/modules/entities/api/records.ts +3 -27
  124. package/src/modules/entities/api/sidebar-entities.ts +43 -14
  125. package/src/modules/entities/lib/entityAcl.ts +58 -0
  126. package/src/modules/messages/api/route.ts +4 -5
  127. package/src/modules/messages/lib/participantScope.ts +64 -0
  128. package/src/modules/planner/components/unavailabilityReasons.ts +3 -3
  129. package/src/modules/progress/AGENTS.md +11 -1
  130. package/src/modules/progress/lib/progressService.ts +1 -0
  131. package/src/modules/progress/lib/progressServiceImpl.ts +385 -117
  132. package/src/modules/resources/api/resources.ts +2 -0
  133. package/src/modules/resources/backend/resources/resources/[id]/page.tsx +9 -3
  134. package/src/modules/resources/commands/resources.ts +10 -0
  135. package/src/modules/resources/components/detail/dictionaries.ts +3 -3
  136. package/src/modules/resources/data/entities.ts +3 -0
  137. package/src/modules/resources/data/validators.ts +5 -0
  138. package/src/modules/resources/lib/seeds.ts +16 -9
  139. package/src/modules/resources/migrations/.snapshot-open-mercato.json +17 -1
  140. package/src/modules/resources/migrations/Migration20260608231000.ts +13 -0
  141. package/src/modules/sales/backend/sales/documents/create/page.tsx +2 -4
  142. package/src/modules/sales/commands/documents.ts +8 -0
  143. package/src/modules/sales/components/documents/ItemsSection.tsx +19 -10
  144. package/src/modules/sales/components/documents/LineItemDialog.tsx +13 -5
  145. package/src/modules/sales/components/documents/SalesDocumentForm.tsx +48 -6
  146. package/src/modules/sales/components/documents/SalesOrderDraftLines.tsx +198 -0
  147. package/src/modules/sales/data/validators.ts +7 -1
  148. package/src/modules/sales/i18n/de.json +2 -0
  149. package/src/modules/sales/i18n/en.json +2 -0
  150. package/src/modules/sales/i18n/es.json +2 -0
  151. package/src/modules/sales/i18n/pl.json +2 -0
  152. package/src/modules/staff/components/detail/dictionaries.ts +3 -3
  153. package/src/modules/workflows/widgets/injection/order-approval/widget.client.tsx +12 -5
@@ -1,11 +1,58 @@
1
1
  import { type DateRangePreset, isValidDateRangePreset } from '@open-mercato/ui/backend/date-range'
2
+ import type { WidgetDataRequest } from '../../../services/widgetDataService'
3
+
4
+ // Deal statuses that mean the deal is closed and must stop counting as current pipeline.
5
+ //
6
+ // This is a denylist, not an allowlist: `customer_deals.status` is a lenient `text` column
7
+ // fed by the per-tenant `deal_status` dictionary, so a status unknown to code counts as OPEN
8
+ // and keeps contributing to the chart. That keeps tenant-specific stages visible and mirrors
9
+ // `customers/lib/interactionStatus.ts`, which treats an unknown interaction status as open.
10
+ //
11
+ // Every vocabulary that reaches the column has to be listed here:
12
+ // - `win` / `loose` are written by the deal closure UI and the kanban board.
13
+ // - `won` / `lost` are written verbatim by the `customers.update_deal_stage` AI tool, whose
14
+ // free-form `toStage` is passed straight through to `status`.
15
+ // - `closed` is a seeded `deal_status` dictionary value, persisted by the dashboards
16
+ // analytics seed and treated as terminal by the demo-data generator.
17
+ export const CLOSED_DEAL_STATUSES = ['win', 'loose', 'won', 'lost', 'closed'] as const
18
+
19
+ // A deal is also closed when `closure_outcome` records the outcome, which the deals-summary
20
+ // KPI already treats as terminal: `api/deals/summary/route.ts` counts a deal as won or lost
21
+ // when EITHER `status` OR `closure_outcome` says so. The two columns can disagree — the CRUD
22
+ // API accepts `closureOutcome` on its own, and the AI tool writes `status` without ever setting
23
+ // it — so the chart has to test both signals to agree with those KPI cards (#4668).
24
+ //
25
+ // The `closure_outcome` side is expressed as `IS NULL` rather than as a `neq` denylist for two
26
+ // reasons, and swapping it for `neq` would be a silent regression:
27
+ // - `closure_outcome` is nullable, and every OPEN deal holds NULL there. `neq` renders as
28
+ // `column != ?`, and in SQL `NULL != 'won'` is NULL, not true — a `neq` filter would drop
29
+ // every open deal and empty the chart. `status` has no such problem: it is `text not null
30
+ // default 'open'`, which is why the status side stays a denylist.
31
+ // - Unlike `status`, this column is not fed by a per-tenant dictionary. Every write path
32
+ // validates it against the same closed `z.enum(['won', 'lost'])` (`data/validators.ts`,
33
+ // `api/deals/[id]/route.ts`, `api/deals/[id]/stats/route.ts`), so a non-null value always
34
+ // means closed and there is no tenant-specific vocabulary to keep visible.
35
+ export const OPEN_DEAL_CLOSURE_OUTCOME_FILTER = {
36
+ field: 'closureOutcome',
37
+ operator: 'is_null',
38
+ } as const
39
+
40
+ export const PIPELINE_STATUS_SCOPES = ['open', 'all'] as const
41
+
42
+ export type PipelineStatusScope = typeof PIPELINE_STATUS_SCOPES[number]
2
43
 
3
44
  export type PipelineSummarySettings = {
4
45
  dateRange: DateRangePreset
46
+ statusScope: PipelineStatusScope
5
47
  }
6
48
 
7
49
  export const DEFAULT_SETTINGS: PipelineSummarySettings = {
8
50
  dateRange: 'this_month',
51
+ statusScope: 'open',
52
+ }
53
+
54
+ function isValidStatusScope(value: unknown): value is PipelineStatusScope {
55
+ return typeof value === 'string' && (PIPELINE_STATUS_SCOPES as readonly string[]).includes(value)
9
56
  }
10
57
 
11
58
  export function hydrateSettings(raw: unknown): PipelineSummarySettings {
@@ -13,5 +60,44 @@ export function hydrateSettings(raw: unknown): PipelineSummarySettings {
13
60
  const obj = raw as Record<string, unknown>
14
61
  return {
15
62
  dateRange: isValidDateRangePreset(obj.dateRange) ? obj.dateRange : DEFAULT_SETTINGS.dateRange,
63
+ statusScope: isValidStatusScope(obj.statusScope) ? obj.statusScope : DEFAULT_SETTINGS.statusScope,
64
+ }
65
+ }
66
+
67
+ export function dehydrateSettings(settings: PipelineSummarySettings): Record<string, unknown> {
68
+ return {
69
+ dateRange: settings.dateRange,
70
+ statusScope: settings.statusScope,
71
+ }
72
+ }
73
+
74
+ export function buildPipelineDataRequest(settings: PipelineSummarySettings): WidgetDataRequest {
75
+ const request: WidgetDataRequest = {
76
+ entityType: 'customers:deals',
77
+ metric: {
78
+ field: 'valueAmount',
79
+ aggregate: 'sum',
80
+ },
81
+ groupBy: {
82
+ field: 'pipelineStage',
83
+ resolveLabels: true,
84
+ },
85
+ dateRange: {
86
+ field: 'createdAt',
87
+ preset: settings.dateRange,
88
+ },
89
+ }
90
+
91
+ if (settings.statusScope === 'open') {
92
+ request.filters = [
93
+ ...CLOSED_DEAL_STATUSES.map((status) => ({
94
+ field: 'status',
95
+ operator: 'neq' as const,
96
+ value: status,
97
+ })),
98
+ { ...OPEN_DEAL_CLOSURE_OUTCOME_FILTER },
99
+ ]
16
100
  }
101
+
102
+ return request
17
103
  }
@@ -10,7 +10,14 @@ import {
10
10
  InlineDateRangeSelect,
11
11
  type DateRangePreset,
12
12
  } from '@open-mercato/ui/backend/date-range'
13
- import { DEFAULT_SETTINGS, hydrateSettings, type PipelineSummarySettings } from './config'
13
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@open-mercato/ui/primitives/select'
14
+ import {
15
+ DEFAULT_SETTINGS,
16
+ buildPipelineDataRequest,
17
+ hydrateSettings,
18
+ type PipelineStatusScope,
19
+ type PipelineSummarySettings,
20
+ } from './config'
14
21
  import type { WidgetDataResponse } from '../../../services/widgetDataService'
15
22
  import { formatCurrencyCompact } from '../../../lib/formatters'
16
23
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -18,23 +25,7 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
18
25
  const logger = createLogger('dashboards').child({ component: 'pipeline-summary' })
19
26
 
20
27
  async function fetchPipelineData(settings: PipelineSummarySettings, fetchWidgetData: WidgetDataFetcher): Promise<WidgetDataResponse> {
21
- const body = {
22
- entityType: 'customers:deals',
23
- metric: {
24
- field: 'valueAmount',
25
- aggregate: 'sum',
26
- },
27
- groupBy: {
28
- field: 'pipelineStage',
29
- resolveLabels: true,
30
- },
31
- dateRange: {
32
- field: 'createdAt',
33
- preset: settings.dateRange,
34
- },
35
- }
36
-
37
- return fetchWidgetData<WidgetDataResponse>(body)
28
+ return fetchWidgetData<WidgetDataResponse>(buildPipelineDataRequest(settings))
38
29
  }
39
30
 
40
31
  function formatStageLabel(stage: unknown, t: (key: string, fallback: string) => string): string {
@@ -97,6 +88,30 @@ const PipelineSummaryWidget: React.FC<DashboardWidgetComponentProps<PipelineSumm
97
88
  value={hydrated.dateRange}
98
89
  onChange={(dateRange: DateRangePreset) => onSettingsChange({ ...hydrated, dateRange })}
99
90
  />
91
+ <div className="space-y-1.5">
92
+ <label
93
+ htmlFor="pipeline-summary-status-scope"
94
+ className="text-xs font-semibold uppercase text-muted-foreground"
95
+ >
96
+ {t('dashboards.analytics.settings.dealStatusScope', 'Deals included')}
97
+ </label>
98
+ <Select
99
+ value={hydrated.statusScope}
100
+ onValueChange={(value) => onSettingsChange({ ...hydrated, statusScope: value as PipelineStatusScope })}
101
+ >
102
+ <SelectTrigger id="pipeline-summary-status-scope" size="sm">
103
+ <SelectValue />
104
+ </SelectTrigger>
105
+ <SelectContent>
106
+ <SelectItem value="open">
107
+ {t('dashboards.analytics.settings.dealStatusScopeOpen', 'Open deals only')}
108
+ </SelectItem>
109
+ <SelectItem value="all">
110
+ {t('dashboards.analytics.settings.dealStatusScopeAll', 'All deals, including won and lost')}
111
+ </SelectItem>
112
+ </SelectContent>
113
+ </Select>
114
+ </div>
100
115
  </div>
101
116
  )
102
117
  }
@@ -1,5 +1,5 @@
1
1
  import { lazyDashboardWidget, type DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
2
- import { DEFAULT_SETTINGS, hydrateSettings, type PipelineSummarySettings } from './config'
2
+ import { DEFAULT_SETTINGS, dehydrateSettings, hydrateSettings, type PipelineSummarySettings } from './config'
3
3
  const PipelineSummaryWidget = lazyDashboardWidget(() => import('./widget.client'))
4
4
 
5
5
  const widget: DashboardWidgetModule<PipelineSummarySettings> = {
@@ -18,7 +18,7 @@ const widget: DashboardWidgetModule<PipelineSummarySettings> = {
18
18
  },
19
19
  Widget: PipelineSummaryWidget,
20
20
  hydrateSettings,
21
- dehydrateSettings: (s) => ({ dateRange: s.dateRange }),
21
+ dehydrateSettings,
22
22
  }
23
23
 
24
24
  export default widget
@@ -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,