@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/resources_resource/index.js +2 -0
- package/dist/generated/entities/resources_resource/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/helpers/integration/salesFixtures.js +31 -2
- package/dist/helpers/integration/salesFixtures.js.map +2 -2
- package/dist/helpers/integration/salesUi.js +5 -0
- package/dist/helpers/integration/salesUi.js.map +2 -2
- package/dist/modules/auth/api/profile/route.js +8 -2
- package/dist/modules/auth/api/profile/route.js.map +2 -2
- package/dist/modules/auth/backend/auth/profile/page.js.map +2 -2
- package/dist/modules/auth/backend/profile/change-password/page.js.map +2 -2
- package/dist/modules/communication_channels/data/enrichers.js +4 -4
- package/dist/modules/communication_channels/data/enrichers.js.map +2 -2
- package/dist/modules/customers/analytics.js +1 -0
- package/dist/modules/customers/analytics.js.map +2 -2
- package/dist/modules/customers/components/detail/DealForm.js +2 -1
- package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
- package/dist/modules/customers/components/detail/DealsSection.js +1 -1
- package/dist/modules/customers/components/detail/DealsSection.js.map +2 -2
- package/dist/modules/customers/data/validators.js +3 -1
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js +52 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +42 -26
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js.map +2 -2
- package/dist/modules/data_sync/lib/queue-policy.js +15 -0
- package/dist/modules/data_sync/lib/queue-policy.js.map +7 -0
- package/dist/modules/data_sync/lib/queue.js +16 -1
- package/dist/modules/data_sync/lib/queue.js.map +2 -2
- package/dist/modules/data_sync/lib/start-run.js +2 -1
- package/dist/modules/data_sync/lib/start-run.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-engine.js +35 -2
- package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-run-service.js +70 -2
- package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
- package/dist/modules/data_sync/workers/sync-export.js +9 -2
- package/dist/modules/data_sync/workers/sync-export.js.map +2 -2
- package/dist/modules/data_sync/workers/sync-import.js +9 -2
- package/dist/modules/data_sync/workers/sync-import.js.map +2 -2
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js +34 -12
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js.map +2 -2
- package/dist/modules/dictionaries/api/openapi.js +15 -2
- package/dist/modules/dictionaries/api/openapi.js.map +2 -2
- package/dist/modules/dictionaries/components/hooks/useDictionaryEntries.js +30 -29
- package/dist/modules/dictionaries/components/hooks/useDictionaryEntries.js.map +2 -2
- package/dist/modules/dictionaries/data/validators.js +7 -0
- package/dist/modules/dictionaries/data/validators.js.map +2 -2
- package/dist/modules/dictionaries/lib/clientEntries.js +4 -3
- package/dist/modules/dictionaries/lib/clientEntries.js.map +2 -2
- package/dist/modules/dictionaries/lib/fetchAllEntries.js +43 -0
- package/dist/modules/dictionaries/lib/fetchAllEntries.js.map +7 -0
- package/dist/modules/entities/api/definitions.js +51 -3
- package/dist/modules/entities/api/definitions.js.map +2 -2
- package/dist/modules/entities/api/entities.js +21 -2
- package/dist/modules/entities/api/entities.js.map +2 -2
- package/dist/modules/entities/api/records.js +3 -24
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/entities/api/sidebar-entities.js +34 -12
- package/dist/modules/entities/api/sidebar-entities.js.map +2 -2
- package/dist/modules/entities/lib/entityAcl.js +46 -0
- package/dist/modules/entities/lib/entityAcl.js.map +2 -2
- package/dist/modules/messages/api/route.js +2 -5
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/lib/participantScope.js +15 -0
- package/dist/modules/messages/lib/participantScope.js.map +7 -0
- package/dist/modules/notifications/api/route.js +3 -1
- package/dist/modules/notifications/api/route.js.map +2 -2
- package/dist/modules/notifications/api/unread-count/route.js +25 -6
- package/dist/modules/notifications/api/unread-count/route.js.map +2 -2
- package/dist/modules/notifications/lib/notificationScope.js +26 -0
- package/dist/modules/notifications/lib/notificationScope.js.map +7 -0
- package/dist/modules/notifications/lib/notificationService.js +7 -3
- package/dist/modules/notifications/lib/notificationService.js.map +2 -2
- package/dist/modules/notifications/lib/routeHelpers.js +16 -2
- package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
- package/dist/modules/planner/components/unavailabilityReasons.js +3 -3
- package/dist/modules/planner/components/unavailabilityReasons.js.map +2 -2
- package/dist/modules/progress/lib/progressService.js +2 -0
- package/dist/modules/progress/lib/progressService.js.map +2 -2
- package/dist/modules/progress/lib/progressServiceImpl.js +322 -104
- package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
- package/dist/modules/resources/api/resources.js +2 -0
- package/dist/modules/resources/api/resources.js.map +2 -2
- package/dist/modules/resources/backend/resources/resources/[id]/page.js +3 -2
- package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
- package/dist/modules/resources/commands/resources.js +9 -0
- package/dist/modules/resources/commands/resources.js.map +2 -2
- package/dist/modules/resources/components/detail/dictionaries.js +3 -3
- package/dist/modules/resources/components/detail/dictionaries.js.map +2 -2
- package/dist/modules/resources/data/entities.js +3 -0
- package/dist/modules/resources/data/entities.js.map +2 -2
- package/dist/modules/resources/data/validators.js +6 -2
- package/dist/modules/resources/data/validators.js.map +2 -2
- package/dist/modules/resources/lib/seeds.js +16 -9
- package/dist/modules/resources/lib/seeds.js.map +2 -2
- package/dist/modules/resources/migrations/Migration20260608231000.js +13 -0
- package/dist/modules/resources/migrations/Migration20260608231000.js.map +7 -0
- package/dist/modules/sales/backend/sales/documents/create/page.js +11 -14
- package/dist/modules/sales/backend/sales/documents/create/page.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +8 -0
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/components/documents/ItemsSection.js +18 -9
- package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/LineItemDialog.js +12 -5
- package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesDocumentForm.js +48 -2
- package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesOrderDraftLines.js +167 -0
- package/dist/modules/sales/components/documents/SalesOrderDraftLines.js.map +7 -0
- package/dist/modules/sales/data/validators.js +5 -1
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/staff/components/detail/dictionaries.js +3 -3
- package/dist/modules/staff/components/detail/dictionaries.js.map +2 -2
- package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +11 -4
- package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
- package/generated/entities/resources_resource/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/helpers/integration/salesFixtures.ts +34 -2
- package/src/helpers/integration/salesUi.ts +8 -0
- package/src/modules/auth/api/profile/route.ts +8 -2
- package/src/modules/auth/backend/auth/profile/page.tsx +1 -0
- package/src/modules/auth/backend/profile/change-password/page.tsx +1 -0
- package/src/modules/communication_channels/data/enrichers.ts +6 -26
- package/src/modules/customers/analytics.ts +1 -0
- package/src/modules/customers/components/detail/DealForm.tsx +5 -1
- package/src/modules/customers/components/detail/DealsSection.tsx +1 -1
- package/src/modules/customers/data/validators.ts +5 -1
- package/src/modules/dashboards/i18n/de.json +3 -0
- package/src/modules/dashboards/i18n/en.json +3 -0
- package/src/modules/dashboards/i18n/es.json +3 -0
- package/src/modules/dashboards/i18n/pl.json +3 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +86 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +33 -18
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.ts +2 -2
- package/src/modules/data_sync/lib/adapter.ts +18 -0
- package/src/modules/data_sync/lib/queue-policy.ts +30 -0
- package/src/modules/data_sync/lib/queue.ts +19 -1
- package/src/modules/data_sync/lib/start-run.ts +2 -1
- package/src/modules/data_sync/lib/sync-engine.ts +46 -0
- package/src/modules/data_sync/lib/sync-run-service.ts +82 -1
- package/src/modules/data_sync/workers/sync-export.ts +8 -1
- package/src/modules/data_sync/workers/sync-import.ts +8 -1
- package/src/modules/dictionaries/api/[dictionaryId]/entries/route.ts +39 -11
- package/src/modules/dictionaries/api/openapi.ts +19 -0
- package/src/modules/dictionaries/components/hooks/useDictionaryEntries.ts +35 -36
- package/src/modules/dictionaries/data/validators.ts +14 -0
- package/src/modules/dictionaries/lib/clientEntries.ts +4 -3
- package/src/modules/dictionaries/lib/fetchAllEntries.ts +72 -0
- package/src/modules/entities/api/definitions.ts +51 -3
- package/src/modules/entities/api/entities.ts +30 -2
- package/src/modules/entities/api/records.ts +3 -27
- package/src/modules/entities/api/sidebar-entities.ts +43 -14
- package/src/modules/entities/lib/entityAcl.ts +58 -0
- package/src/modules/messages/api/route.ts +4 -5
- package/src/modules/messages/lib/participantScope.ts +64 -0
- package/src/modules/notifications/api/route.ts +2 -0
- package/src/modules/notifications/api/unread-count/route.ts +42 -5
- package/src/modules/notifications/lib/notificationScope.ts +40 -0
- package/src/modules/notifications/lib/notificationService.ts +5 -0
- package/src/modules/notifications/lib/routeHelpers.ts +19 -2
- package/src/modules/planner/components/unavailabilityReasons.ts +3 -3
- package/src/modules/progress/AGENTS.md +11 -1
- package/src/modules/progress/lib/progressService.ts +1 -0
- package/src/modules/progress/lib/progressServiceImpl.ts +385 -117
- package/src/modules/resources/api/resources.ts +2 -0
- package/src/modules/resources/backend/resources/resources/[id]/page.tsx +9 -3
- package/src/modules/resources/commands/resources.ts +10 -0
- package/src/modules/resources/components/detail/dictionaries.ts +3 -3
- package/src/modules/resources/data/entities.ts +3 -0
- package/src/modules/resources/data/validators.ts +5 -0
- package/src/modules/resources/lib/seeds.ts +16 -9
- package/src/modules/resources/migrations/.snapshot-open-mercato.json +17 -1
- package/src/modules/resources/migrations/Migration20260608231000.ts +13 -0
- package/src/modules/sales/backend/sales/documents/create/page.tsx +2 -4
- package/src/modules/sales/commands/documents.ts +8 -0
- package/src/modules/sales/components/documents/ItemsSection.tsx +19 -10
- package/src/modules/sales/components/documents/LineItemDialog.tsx +13 -5
- package/src/modules/sales/components/documents/SalesDocumentForm.tsx +48 -6
- package/src/modules/sales/components/documents/SalesOrderDraftLines.tsx +198 -0
- package/src/modules/sales/data/validators.ts +7 -1
- package/src/modules/sales/i18n/de.json +2 -0
- package/src/modules/sales/i18n/en.json +2 -0
- package/src/modules/sales/i18n/es.json +2 -0
- package/src/modules/sales/i18n/pl.json +2 -0
- package/src/modules/staff/components/detail/dictionaries.ts +3 -3
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
63
|
-
if (!
|
|
63
|
+
const entries = await fetchAllDictionaryEntries(dictionary.id)
|
|
64
|
+
if (!entries.ok) return []
|
|
64
65
|
|
|
65
|
-
return normalizeDictionaryEntries(
|
|
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
|
|
230
|
-
if (!
|
|
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())
|
|
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 {
|
|
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
|
|
90
|
-
if (
|
|
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
|
-
|
|
31
|
-
|
|
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.
|
|
47
|
+
if (cached && Array.isArray(cached.candidates)) candidates = cached.candidates
|
|
36
48
|
}
|
|
37
49
|
} catch {}
|
|
38
50
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|