@open-mercato/core 0.4.11-develop.1362.574a071900 → 0.4.11-develop.1366.20ce92f196

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.
@@ -0,0 +1,359 @@
1
+ import * as React from 'react'
2
+ import type { FilterQuery } from '@mikro-orm/core'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import type { AwilixContainer } from 'awilix'
5
+ import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
6
+ import type { Module } from '@open-mercato/shared/modules/registry'
7
+ import type {
8
+ BackendChromePayload,
9
+ BackendChromeNavGroup,
10
+ BackendChromeNavItem,
11
+ BackendChromeSectionGroup,
12
+ BackendChromeSectionItem,
13
+ } from '@open-mercato/shared/modules/navigation/backendChrome'
14
+ import {
15
+ buildAdminNav,
16
+ buildSettingsSections,
17
+ computeSettingsPathPrefixes,
18
+ convertToSectionNavGroups,
19
+ type AdminNavItem,
20
+ } from '@open-mercato/ui/backend/utils/nav'
21
+ import { profilePathPrefixes, profileSections } from './profile-sections'
22
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
23
+ import { resolveFeatureCheckContext } from '@open-mercato/core/modules/directory/utils/organizationScope'
24
+ import { CustomEntity } from '@open-mercato/core/modules/entities/data/entities'
25
+ import { Role } from '@open-mercato/core/modules/auth/data/entities'
26
+ import {
27
+ applySidebarPreference,
28
+ loadFirstRoleSidebarPreference,
29
+ loadSidebarPreference,
30
+ } from '@open-mercato/core/modules/auth/services/sidebarPreferencesService'
31
+ import type { SidebarPreferencesSettings } from '@open-mercato/shared/modules/navigation/sidebarPreferences'
32
+
33
+ type TranslationFn = (key: string | undefined, fallback: string) => string
34
+
35
+ type RouteModule = Pick<Module, 'id' | 'backendRoutes'>
36
+
37
+ type SerializableSectionItem = {
38
+ id: string
39
+ label: string
40
+ labelKey?: string
41
+ href: string
42
+ icon?: React.ReactNode
43
+ order?: number
44
+ children?: SerializableSectionItem[]
45
+ }
46
+
47
+ type SerializableSectionGroup = {
48
+ id: string
49
+ label: string
50
+ labelKey?: string
51
+ order?: number
52
+ items: SerializableSectionItem[]
53
+ }
54
+
55
+ type ResolvedNavItem = Omit<BackendChromeNavItem, 'defaultTitle' | 'children'> & {
56
+ defaultTitle: string
57
+ children?: ResolvedNavItem[]
58
+ }
59
+
60
+ type ResolveBackendChromePayloadArgs = {
61
+ auth: Exclude<AuthContext, null>
62
+ locale: string
63
+ modules: RouteModule[]
64
+ translate: TranslationFn
65
+ selectedOrganizationId?: string | null
66
+ selectedTenantId?: string | null
67
+ }
68
+
69
+ const settingsSectionOrder: Record<string, number> = {
70
+ system: 1,
71
+ auth: 2,
72
+ 'customer-portal': 3,
73
+ 'data-designer': 4,
74
+ 'module-configs': 5,
75
+ directory: 6,
76
+ 'feature-toggles': 7,
77
+ }
78
+
79
+ type NavGroupWithWeight = Omit<BackendChromeNavGroup, 'id' | 'defaultName' | 'items'> & {
80
+ id: string
81
+ defaultName: string
82
+ items: ResolvedNavItem[]
83
+ weight: number
84
+ }
85
+
86
+ let renderToStaticMarkupPromise: Promise<typeof import('react-dom/server')> | null = null
87
+
88
+ async function serializeIconMarkup(icon: React.ReactNode | undefined): Promise<string | undefined> {
89
+ if (!icon) return undefined
90
+ if (!renderToStaticMarkupPromise) {
91
+ renderToStaticMarkupPromise = import('react-dom/server')
92
+ }
93
+ const { renderToStaticMarkup } = await renderToStaticMarkupPromise
94
+ const markup = renderToStaticMarkup(<>{icon}</>)
95
+ return markup.trim().length > 0 ? markup : undefined
96
+ }
97
+
98
+ async function serializeNavItem(item: AdminNavItem): Promise<ResolvedNavItem> {
99
+ return {
100
+ id: item.href,
101
+ href: item.href,
102
+ title: item.title,
103
+ defaultTitle: item.defaultTitle,
104
+ enabled: item.enabled,
105
+ hidden: item.hidden,
106
+ pageContext: item.pageContext,
107
+ iconMarkup: await serializeIconMarkup(item.icon),
108
+ children: item.children ? await Promise.all(item.children.map((child) => serializeNavItem(child))) : undefined,
109
+ }
110
+ }
111
+
112
+ function normalizeGroupWeights(groups: NavGroupWithWeight[]): NavGroupWithWeight[] {
113
+ const defaultGroupOrder = [
114
+ 'customers.nav.group',
115
+ 'catalog.nav.group',
116
+ 'customers~sales.nav.group',
117
+ 'resources.nav.group',
118
+ 'staff.nav.group',
119
+ 'entities.nav.group',
120
+ 'directory.nav.group',
121
+ 'customers.storage.nav.group',
122
+ ]
123
+ const groupOrderIndex = new Map(defaultGroupOrder.map((id, index) => [id, index]))
124
+ groups.sort((a, b) => {
125
+ const aIndex = groupOrderIndex.get(a.id)
126
+ const bIndex = groupOrderIndex.get(b.id)
127
+ if (aIndex !== undefined || bIndex !== undefined) {
128
+ if (aIndex === undefined) return 1
129
+ if (bIndex === undefined) return -1
130
+ if (aIndex !== bIndex) return aIndex - bIndex
131
+ }
132
+ if (a.weight !== b.weight) return a.weight - b.weight
133
+ return a.name.localeCompare(b.name)
134
+ })
135
+ const defaultGroupCount = defaultGroupOrder.length
136
+ groups.forEach((group, index) => {
137
+ const rank = groupOrderIndex.get(group.id)
138
+ const fallbackWeight = typeof group.weight === 'number' ? group.weight : 10_000
139
+ group.weight =
140
+ (rank !== undefined ? rank : defaultGroupCount + index) * 1_000_000 +
141
+ Math.min(Math.max(fallbackWeight, 0), 999_999)
142
+ })
143
+ return groups
144
+ }
145
+
146
+ async function groupEntries(entries: AdminNavItem[]): Promise<NavGroupWithWeight[]> {
147
+ const groupMap = new Map<string, NavGroupWithWeight>()
148
+ for (const entry of entries) {
149
+ const weight = entry.priority ?? entry.order ?? 10_000
150
+ const serializedItem = await serializeNavItem(entry)
151
+ const existing = groupMap.get(entry.groupId)
152
+ if (existing) {
153
+ existing.items.push(serializedItem)
154
+ if (weight < existing.weight) existing.weight = weight
155
+ continue
156
+ }
157
+ groupMap.set(entry.groupId, {
158
+ id: entry.groupId,
159
+ name: entry.group,
160
+ defaultName: entry.groupDefaultName,
161
+ items: [serializedItem],
162
+ weight,
163
+ })
164
+ }
165
+ return normalizeGroupWeights(Array.from(groupMap.values()))
166
+ }
167
+
168
+ function adoptSidebarDefaults(groups: NavGroupWithWeight[]): NavGroupWithWeight[] {
169
+ const adoptItems = (items: ResolvedNavItem[]): ResolvedNavItem[] =>
170
+ items.map((item) => ({
171
+ ...item,
172
+ defaultTitle: item.title,
173
+ children: item.children ? adoptItems(item.children) : undefined,
174
+ }))
175
+
176
+ return groups.map((group) => ({
177
+ ...group,
178
+ defaultName: group.name,
179
+ items: adoptItems(group.items),
180
+ }))
181
+ }
182
+
183
+ async function serializeSectionItem(item: {
184
+ id: string
185
+ label: string
186
+ labelKey?: string
187
+ href: string
188
+ icon?: React.ReactNode
189
+ order?: number
190
+ children?: SerializableSectionItem[]
191
+ }): Promise<BackendChromeSectionItem> {
192
+ return {
193
+ id: item.id,
194
+ label: item.label,
195
+ labelKey: item.labelKey,
196
+ href: item.href,
197
+ order: item.order,
198
+ iconMarkup: await serializeIconMarkup(item.icon),
199
+ children: item.children ? await Promise.all(item.children.map((child) => serializeSectionItem(child))) : undefined,
200
+ }
201
+ }
202
+
203
+ async function serializeSectionGroups(groups: SerializableSectionGroup[]): Promise<BackendChromeSectionGroup[]> {
204
+ return Promise.all(groups.map(async (group) => ({
205
+ id: group.id,
206
+ label: group.label,
207
+ labelKey: group.labelKey,
208
+ order: group.order,
209
+ items: await Promise.all(group.items.map((item) => serializeSectionItem(item))),
210
+ })))
211
+ }
212
+
213
+ async function loadScopedContainer(): Promise<AwilixContainer> {
214
+ return createRequestContainer()
215
+ }
216
+
217
+ export async function resolveBackendChromePayload({
218
+ auth,
219
+ locale,
220
+ modules,
221
+ translate,
222
+ selectedOrganizationId,
223
+ selectedTenantId,
224
+ }: ResolveBackendChromePayloadArgs): Promise<BackendChromePayload> {
225
+ const container = await loadScopedContainer()
226
+ const em = container.resolve('em') as EntityManager
227
+ const rbac = container.resolve('rbacService') as {
228
+ loadAcl: (userId: string, scope: { tenantId: string | null; organizationId: string | null }) => Promise<{
229
+ isSuperAdmin: boolean
230
+ features: string[]
231
+ }>
232
+ }
233
+
234
+ let scopedOrganizationId: string | null = auth.orgId ?? null
235
+ let scopedTenantId: string | null = auth.tenantId ?? null
236
+ let allowNavigation = true
237
+
238
+ try {
239
+ const { organizationId, scope, allowedOrganizationIds } = await resolveFeatureCheckContext({
240
+ container,
241
+ auth,
242
+ selectedId: selectedOrganizationId,
243
+ tenantId: selectedTenantId,
244
+ })
245
+ scopedOrganizationId = organizationId
246
+ scopedTenantId = scope.tenantId ?? auth.tenantId ?? null
247
+ if (Array.isArray(allowedOrganizationIds) && allowedOrganizationIds.length === 0) {
248
+ allowNavigation = false
249
+ }
250
+ } catch {
251
+ scopedOrganizationId = auth.orgId ?? null
252
+ scopedTenantId = auth.tenantId ?? null
253
+ }
254
+
255
+ const acl = allowNavigation
256
+ ? await rbac.loadAcl(auth.sub, {
257
+ tenantId: scopedTenantId,
258
+ organizationId: scopedOrganizationId,
259
+ })
260
+ : { isSuperAdmin: false, features: [] }
261
+
262
+ const grantedFeatures = acl.isSuperAdmin ? ['*'] : acl.features
263
+ const featureChecker = async (): Promise<string[]> => grantedFeatures
264
+
265
+ let userEntities: Array<{ entityId: string; label: string; href: string }> = []
266
+ if (allowNavigation) {
267
+ try {
268
+ const where: FilterQuery<CustomEntity> = {
269
+ isActive: true,
270
+ showInSidebar: true,
271
+ }
272
+ where.$and = [
273
+ { $or: [{ organizationId: scopedOrganizationId ?? undefined }, { organizationId: null }] },
274
+ { $or: [{ tenantId: scopedTenantId ?? undefined }, { tenantId: null }] },
275
+ ]
276
+ const entities = await em.find(CustomEntity, where, { orderBy: { label: 'asc' } })
277
+ userEntities = entities.map((entity) => ({
278
+ entityId: entity.entityId,
279
+ label: entity.label,
280
+ href: `/backend/entities/user/${encodeURIComponent(entity.entityId)}/records`,
281
+ }))
282
+ } catch {
283
+ userEntities = []
284
+ }
285
+ }
286
+
287
+ const ctxAuth = {
288
+ roles: auth.roles || [],
289
+ sub: auth.sub,
290
+ tenantId: scopedTenantId,
291
+ orgId: scopedOrganizationId,
292
+ }
293
+ const entries = allowNavigation
294
+ ? await buildAdminNav(
295
+ modules,
296
+ { auth: ctxAuth },
297
+ userEntities,
298
+ translate,
299
+ { checkFeatures: featureChecker },
300
+ )
301
+ : []
302
+
303
+ let rolePreference: SidebarPreferencesSettings | null = null
304
+ let userPreference: SidebarPreferencesSettings | null = null
305
+
306
+ if (Array.isArray(auth.roles) && auth.roles.length > 0) {
307
+ const roleScope: FilterQuery<Role> = scopedTenantId
308
+ ? { $or: [{ tenantId: scopedTenantId }, { tenantId: null }] }
309
+ : { tenantId: null }
310
+ const roleRecords = await em.find(Role, {
311
+ name: { $in: auth.roles },
312
+ ...roleScope,
313
+ })
314
+ const roleIds = Array.isArray(roleRecords) ? roleRecords.map((role) => role.id) : []
315
+ if (roleIds.length > 0) {
316
+ rolePreference = await loadFirstRoleSidebarPreference(em, {
317
+ roleIds,
318
+ tenantId: scopedTenantId,
319
+ locale,
320
+ })
321
+ }
322
+ }
323
+
324
+ const effectiveUserId = auth.isApiKey ? auth.userId : auth.sub
325
+ if (effectiveUserId) {
326
+ userPreference = await loadSidebarPreference(em, {
327
+ userId: effectiveUserId,
328
+ tenantId: scopedTenantId,
329
+ organizationId: scopedOrganizationId,
330
+ locale,
331
+ })
332
+ }
333
+
334
+ const baseGroups = await groupEntries(entries)
335
+ const groupsWithRole = rolePreference
336
+ ? applySidebarPreference<NavGroupWithWeight>(baseGroups, rolePreference)
337
+ : baseGroups
338
+ const baseForUser = adoptSidebarDefaults(groupsWithRole)
339
+ const appliedGroups = userPreference
340
+ ? applySidebarPreference<NavGroupWithWeight>(baseForUser, userPreference)
341
+ : baseForUser
342
+
343
+ const settingsSections = await serializeSectionGroups(
344
+ convertToSectionNavGroups(
345
+ buildSettingsSections(entries, settingsSectionOrder),
346
+ translate,
347
+ ),
348
+ )
349
+
350
+ return {
351
+ groups: appliedGroups.map(({ weight: _weight, ...group }) => group),
352
+ settingsSections,
353
+ settingsPathPrefixes: computeSettingsPathPrefixes(buildSettingsSections(entries, settingsSectionOrder)),
354
+ profileSections: await serializeSectionGroups(profileSections),
355
+ profilePathPrefixes,
356
+ grantedFeatures,
357
+ roles: Array.isArray(auth.roles) ? auth.roles : [],
358
+ }
359
+ }