@open-mercato/core 0.6.6-develop.6345.2.b236ea3d4b → 0.6.6-develop.6351.1.c140d703c9

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.
@@ -24,6 +24,15 @@ import {
24
24
  beginEntitiesMutationGuard,
25
25
  FIELD_DEFINITION_RESOURCE_KIND,
26
26
  } from './definitions.mutation-guard'
27
+ import {
28
+ createExactDefinitionWhere,
29
+ createScopedDefinitionTombstone,
30
+ createVisibleDefinitionWhere,
31
+ markDefinitionTombstoned,
32
+ resolveDefinitionScopeFromOrganizationScope,
33
+ resolveDefinitionMutationScope,
34
+ selectVisibleDefinitionWinner,
35
+ } from '../lib/definition-scope'
27
36
 
28
37
  /**
29
38
  * Validate defaultValue against the field kind. Returns an error message string
@@ -188,6 +197,19 @@ function normalizeFieldGroup(raw: unknown): { code: string; title?: string; hint
188
197
  return group
189
198
  }
190
199
 
200
+ function definitionMatchesReadScope(
201
+ definition: { tenantId?: string | null; organizationId?: string | null },
202
+ scope: { tenantId: string | null; organizationId: string | null },
203
+ ) {
204
+ const definitionTenantId = definition.tenantId ?? null
205
+ const definitionOrganizationId = definition.organizationId ?? null
206
+ const tenantMatches = definitionTenantId === null || definitionTenantId === scope.tenantId
207
+ const organizationMatches =
208
+ definitionOrganizationId === null ||
209
+ (scope.organizationId !== null && definitionOrganizationId === scope.organizationId)
210
+ return tenantMatches && organizationMatches
211
+ }
212
+
191
213
  export async function GET(req: Request) {
192
214
  const url = new URL(req.url)
193
215
  const requestedEntityIds = parseEntityIds(url)
@@ -207,11 +229,12 @@ export async function GET(req: Request) {
207
229
 
208
230
  const container = await createRequestContainer()
209
231
  const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
210
- const tenantId = scope.tenantId ?? auth.tenantId ?? null
232
+ const definitionScope = resolveDefinitionScopeFromOrganizationScope(auth, scope)
233
+ const tenantId = definitionScope.tenantId
211
234
  if (!tenantId) {
212
235
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
213
236
  }
214
- const organizationId = scope.selectedId ?? auth.orgId ?? null
237
+ const organizationId = definitionScope.organizationId
215
238
  const { resolve } = container
216
239
  const em = resolve('em') as any
217
240
  let cache: CacheStrategy | undefined
@@ -273,22 +296,29 @@ export async function GET(req: Request) {
273
296
  mode: 'public',
274
297
  })
275
298
 
276
- // Tenant-only scoping: allow global (null) or exact tenant match; do not scope by organization here
299
+ const tenantCandidates = [{ tenantId }, { tenantId: null as string | null }]
300
+ const organizationCandidates = [{ organizationId: null as string | null }]
301
+ if (organizationId) organizationCandidates.unshift({ organizationId })
302
+ const readScope = { tenantId, organizationId }
303
+
277
304
  const whereActive = {
278
305
  entityId: { $in: entityIds as any },
279
306
  deletedAt: null,
280
307
  $and: [
281
- { $or: [ { tenantId: tenantId ?? undefined as any }, { tenantId: null } ] },
308
+ { $or: tenantCandidates },
309
+ { $or: organizationCandidates },
282
310
  ],
283
311
  } as any
284
- const defs = await em.find(CustomFieldDef, whereActive as any)
285
- const tombstones = await em.find(CustomFieldDef, {
312
+ const defs = (await em.find(CustomFieldDef, whereActive as any))
313
+ .filter((definition: any) => definitionMatchesReadScope(definition, readScope))
314
+ const tombstones = (await em.find(CustomFieldDef, {
286
315
  entityId: { $in: entityIds as any },
287
316
  deletedAt: { $ne: null } as any,
288
317
  $and: [
289
- { $or: [ { tenantId: tenantId ?? undefined as any }, { tenantId: null } ] },
318
+ { $or: tenantCandidates },
319
+ { $or: organizationCandidates },
290
320
  ],
291
- } as any)
321
+ } as any)).filter((definition: any) => definitionMatchesReadScope(definition, readScope))
292
322
 
293
323
  const tombstonedByEntity = new Map<string, Set<string>>()
294
324
  for (const entry of tombstones as any[]) {
@@ -468,6 +498,7 @@ export async function POST(req: Request) {
468
498
  }
469
499
 
470
500
  const container = await createRequestContainer()
501
+ const scope = await resolveDefinitionMutationScope({ auth, container, request: req })
471
502
  const { resolve } = container
472
503
  const em = resolve('em') as any
473
504
  let cache: CacheStrategy | undefined
@@ -475,7 +506,7 @@ export async function POST(req: Request) {
475
506
  cache = resolve('cache') as CacheStrategy
476
507
  } catch {}
477
508
 
478
- const where: any = { entityId: input.entityId, key: input.key, organizationId: auth.orgId ?? null, tenantId: auth.tenantId ?? null }
509
+ const where: any = createExactDefinitionWhere(input.entityId, input.key, scope)
479
510
  let def = await em.findOne(CustomFieldDef, where)
480
511
 
481
512
  const guard = await beginEntitiesMutationGuard({
@@ -512,7 +543,7 @@ export async function POST(req: Request) {
512
543
  if (cfg.defaultValue !== undefined && cfg.defaultValue !== null) {
513
544
  const validationError = await validateDefaultValueByKind(
514
545
  cfg.defaultValue, input.kind, cfg, em,
515
- { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null },
546
+ { tenantId: scope.tenantId, organizationId: scope.organizationId },
516
547
  )
517
548
  if (validationError) {
518
549
  return NextResponse.json({ error: validationError }, { status: 400 })
@@ -521,13 +552,14 @@ export async function POST(req: Request) {
521
552
  }
522
553
  def.configJson = cfg
523
554
  def.isActive = input.isActive ?? true
555
+ def.deletedAt = def.isActive === false ? (def.deletedAt ?? new Date()) : null
524
556
  def.updatedAt = new Date()
525
557
  em.persist(def)
526
558
  await em.flush()
527
559
  await guard.runAfterSuccess()
528
560
  await invalidateDefinitionsCache(cache, {
529
- tenantId: auth.tenantId ?? null,
530
- organizationId: auth.orgId ?? null,
561
+ tenantId: scope.tenantId,
562
+ organizationId: scope.organizationId,
531
563
  entityIds: [input.entityId],
532
564
  })
533
565
  // Changing field definitions may impact forms but not sidebar items; no nav cache touch
@@ -543,36 +575,48 @@ export async function DELETE(req: Request) {
543
575
  if (!entityId || !key) return NextResponse.json({ error: 'entityId and key are required' }, { status: 400 })
544
576
 
545
577
  const container = await createRequestContainer()
578
+ const scope = await resolveDefinitionMutationScope({ auth, container, request: req })
546
579
  const { resolve } = container
547
580
  const em = resolve('em') as any
548
581
  let cache: CacheStrategy | undefined
549
582
  try {
550
583
  cache = resolve('cache') as CacheStrategy
551
584
  } catch {}
552
- const where: any = { entityId, key, organizationId: auth.orgId ?? null, tenantId: auth.tenantId ?? null }
553
- const def = await em.findOne(CustomFieldDef, where)
554
- if (!def) return NextResponse.json({ error: 'Not found' }, { status: 404 })
585
+ const where: any = createExactDefinitionWhere(entityId, key, scope)
586
+ let def = await em.findOne(CustomFieldDef, where)
587
+ let inherited: any | null = null
588
+ if (!def) {
589
+ inherited = selectVisibleDefinitionWinner(await em.find(CustomFieldDef, createVisibleDefinitionWhere(
590
+ entityId,
591
+ key,
592
+ scope,
593
+ { deletedAt: null, isActive: true },
594
+ )))
595
+ if (!inherited) return NextResponse.json({ error: 'Not found' }, { status: 404 })
596
+ }
555
597
 
556
598
  const guard = await beginEntitiesMutationGuard({
557
599
  container,
558
600
  auth,
559
601
  req,
560
602
  resourceKind: FIELD_DEFINITION_RESOURCE_KIND,
561
- resourceId: def.id,
603
+ resourceId: def?.id ?? inherited?.id ?? `${entityId}:${key}`,
562
604
  operation: 'delete',
563
605
  mutationPayload: { entityId, key },
564
606
  })
565
607
  if (guard.blockedResponse) return guard.blockedResponse
566
608
 
567
- def.isActive = false
568
- def.updatedAt = new Date()
569
- def.deletedAt = def.deletedAt ?? new Date()
609
+ if (!def) {
610
+ def = createScopedDefinitionTombstone(em, inherited, scope)
611
+ } else {
612
+ markDefinitionTombstoned(def)
613
+ }
570
614
  em.persist(def)
571
615
  await em.flush()
572
616
  await guard.runAfterSuccess()
573
617
  await invalidateDefinitionsCache(cache, {
574
- tenantId: auth.tenantId ?? null,
575
- organizationId: auth.orgId ?? null,
618
+ tenantId: scope.tenantId,
619
+ organizationId: scope.organizationId,
576
620
  entityIds: [entityId],
577
621
  })
578
622
  // Changing field definitions may impact forms but not sidebar items; no nav cache touch
@@ -27,6 +27,7 @@ import { normalizeCustomFieldOptions } from '@open-mercato/shared/modules/entiti
27
27
  import { TranslationManager } from '@open-mercato/core/modules/translations/components/TranslationManager'
28
28
 
29
29
  type Def = FieldDefinition
30
+ export type EntitySource = 'code' | 'custom'
30
31
  type EntitiesListResponse = { items?: Array<Record<string, unknown>> }
31
32
  type FieldsetGroup = { code: string; title?: string; hint?: string }
32
33
  type FieldsetDefinition = { code: string; label: string; icon?: string; description?: string; groups?: FieldsetGroup[] }
@@ -43,7 +44,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
43
44
  const queryClient = useQueryClient()
44
45
  const entityId = useMemo(() => decodeURIComponent((params?.entityId as any) || ''), [params])
45
46
  const [label, setLabel] = useState('')
46
- const [entitySource, setEntitySource] = useState<'code'|'custom'>('custom')
47
+ const [entitySource, setEntitySource] = useState<EntitySource>('custom')
47
48
  const [entityFormLoading, setEntityFormLoading] = useState(true)
48
49
  const [entityInitial, setEntityInitial] = useState<{ label?: string; description?: string; labelField?: string; defaultEditor?: string; showInSidebar?: boolean; updatedAt?: string }>({})
49
50
  const [defs, setDefs] = useState<Def[]>([])
@@ -391,13 +392,15 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
391
392
  showInSidebar: z.boolean().optional(),
392
393
  }) as z.ZodType<Record<string, unknown>>
393
394
 
395
+ const metadataFieldsReadOnly = entitySource === 'code'
394
396
  const fields: CrudField[] = [
395
- { id: 'label', label: 'Label', type: 'text', required: true },
396
- { id: 'description', label: 'Description', type: 'textarea' },
397
+ { id: 'label', label: 'Label', type: 'text', required: true, readOnly: metadataFieldsReadOnly },
398
+ { id: 'description', label: 'Description', type: 'textarea', readOnly: metadataFieldsReadOnly },
397
399
  {
398
400
  id: 'defaultEditor',
399
401
  label: 'Default Editor (multiline)',
400
402
  type: 'select',
403
+ readOnly: metadataFieldsReadOnly,
401
404
  options: [
402
405
  { value: '', label: 'Default (Markdown)' },
403
406
  { value: 'markdown', label: 'Markdown (UIW)' },
@@ -493,17 +496,12 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
493
496
  }
494
497
  try { window.dispatchEvent(new Event('om:refresh-sidebar')) } catch {}
495
498
  }
496
- const defsPayload = {
499
+ const defsPayload = buildDefinitionsBatchPayload({
497
500
  entityId,
498
- definitions: defs.filter((d) => !!d.key).map((d) => ({
499
- key: d.key,
500
- kind: d.kind,
501
- configJson: d.configJson,
502
- isActive: d.isActive !== false,
503
- })),
501
+ defs,
504
502
  fieldsets: buildFieldsetPayload(),
505
503
  singleFieldsetPerRecord,
506
- }
504
+ })
507
505
  const callDefs = await apiCall('/api/entities/definitions.batch', {
508
506
  method: 'POST',
509
507
  headers: { 'content-type': 'application/json' },
@@ -607,12 +605,31 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
607
605
  )
608
606
  }
609
607
 
610
- export function shouldRegisterEntityMetadata(entitySource: 'code' | 'custom'): boolean {
608
+ export function shouldRegisterEntityMetadata(entitySource: EntitySource): boolean {
611
609
  return entitySource === 'custom'
612
610
  }
613
611
 
612
+ export function buildDefinitionsBatchPayload(options: {
613
+ entityId: string
614
+ defs: Array<Pick<Def, 'key' | 'kind' | 'configJson' | 'isActive'>>
615
+ fieldsets: FieldsetDefinition[]
616
+ singleFieldsetPerRecord: boolean
617
+ }) {
618
+ return {
619
+ entityId: options.entityId,
620
+ definitions: options.defs.filter((d) => !!d.key).map((d) => ({
621
+ key: d.key,
622
+ kind: d.kind,
623
+ configJson: d.configJson,
624
+ isActive: d.isActive !== false,
625
+ })),
626
+ fieldsets: options.fieldsets,
627
+ singleFieldsetPerRecord: options.singleFieldsetPerRecord,
628
+ }
629
+ }
630
+
614
631
  export function buildEntityMetadataPayload(
615
- entitySource: 'code' | 'custom',
632
+ entitySource: EntitySource,
616
633
  vals: Record<string, unknown>,
617
634
  ): Record<string, unknown> | null {
618
635
  const partial = entitySource === 'custom'
@@ -0,0 +1,172 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
3
+ import { CustomFieldDef } from '@open-mercato/core/modules/entities/data/entities'
4
+
5
+ type AuthScope = {
6
+ tenantId?: string | null
7
+ orgId?: string | null
8
+ }
9
+
10
+ export type DefinitionMutationScope = {
11
+ tenantId: string | null
12
+ organizationId: string | null
13
+ }
14
+
15
+ type OrganizationScopeLike = {
16
+ tenantId?: string | null
17
+ selectedId?: string | null
18
+ }
19
+
20
+ type DefinitionKeySelector = string | { $in: string[] }
21
+
22
+ type DefinitionVisibilityOptions = {
23
+ deletedAt?: null | { $ne: null }
24
+ isActive?: boolean
25
+ }
26
+
27
+ type DefinitionScopeRecord = {
28
+ tenantId?: string | null
29
+ organizationId?: string | null
30
+ updatedAt?: Date | string | number | null
31
+ createdAt?: Date | string | number | null
32
+ }
33
+
34
+ type MutableDefinitionScopeRecord = DefinitionScopeRecord & {
35
+ isActive?: boolean
36
+ deletedAt?: Date | string | number | null
37
+ }
38
+
39
+ export function resolveDefinitionScopeFromOrganizationScope(
40
+ auth: AuthScope,
41
+ scope: OrganizationScopeLike,
42
+ ): DefinitionMutationScope {
43
+ const authTenantId = auth.tenantId ?? null
44
+ const scopeTenantId = scope.tenantId ?? null
45
+ const tenantMismatch = Boolean(authTenantId && scopeTenantId && authTenantId !== scopeTenantId)
46
+
47
+ return {
48
+ tenantId: tenantMismatch ? authTenantId : (scopeTenantId ?? authTenantId),
49
+ organizationId: tenantMismatch
50
+ ? (auth.orgId ?? null)
51
+ : (scope.selectedId ?? auth.orgId ?? null),
52
+ }
53
+ }
54
+
55
+ export async function resolveDefinitionMutationScope({
56
+ auth,
57
+ container,
58
+ request,
59
+ }: {
60
+ auth: AuthScope
61
+ container: unknown
62
+ request: Request
63
+ }): Promise<DefinitionMutationScope> {
64
+ const scope = await resolveOrganizationScopeForRequest({ container, auth, request } as any)
65
+ return resolveDefinitionScopeFromOrganizationScope(auth, scope)
66
+ }
67
+
68
+ export function createExactDefinitionWhere(
69
+ entityId: string,
70
+ key: DefinitionKeySelector,
71
+ scope: DefinitionMutationScope,
72
+ ) {
73
+ return {
74
+ entityId,
75
+ key,
76
+ organizationId: scope.organizationId,
77
+ tenantId: scope.tenantId,
78
+ }
79
+ }
80
+
81
+ export function createVisibleDefinitionWhere(
82
+ entityId: string,
83
+ key: DefinitionKeySelector,
84
+ scope: DefinitionMutationScope,
85
+ options: DefinitionVisibilityOptions = {},
86
+ ) {
87
+ const organizationCandidates = [{ organizationId: null as string | null }]
88
+ if (scope.organizationId) organizationCandidates.unshift({ organizationId: scope.organizationId })
89
+
90
+ const tenantCandidates = [{ tenantId: null as string | null }]
91
+ if (scope.tenantId) tenantCandidates.unshift({ tenantId: scope.tenantId })
92
+
93
+ return {
94
+ entityId,
95
+ key,
96
+ ...options,
97
+ $and: [
98
+ { $or: organizationCandidates },
99
+ { $or: tenantCandidates },
100
+ ],
101
+ }
102
+ }
103
+
104
+ function definitionTimestamp(def: DefinitionScopeRecord) {
105
+ const updatedAt = def?.updatedAt instanceof Date
106
+ ? def.updatedAt.getTime()
107
+ : (def?.updatedAt ? new Date(def.updatedAt).getTime() : 0)
108
+ if (updatedAt) return updatedAt
109
+ return def?.createdAt instanceof Date
110
+ ? def.createdAt.getTime()
111
+ : (def?.createdAt ? new Date(def.createdAt).getTime() : 0)
112
+ }
113
+
114
+ function definitionScopeScore(def: DefinitionScopeRecord) {
115
+ return (def?.tenantId ? 2 : 0) + (def?.organizationId ? 1 : 0)
116
+ }
117
+
118
+ export function selectVisibleDefinitionWinner<T extends DefinitionScopeRecord>(definitions: T[]) {
119
+ let winner: T | null = null
120
+ for (const definition of definitions) {
121
+ if (!winner) {
122
+ winner = definition
123
+ continue
124
+ }
125
+ const nextScore = definitionScopeScore(definition)
126
+ const winnerScore = definitionScopeScore(winner)
127
+ if (nextScore > winnerScore) {
128
+ winner = definition
129
+ continue
130
+ }
131
+ if (nextScore < winnerScore) continue
132
+ if (definitionTimestamp(definition) >= definitionTimestamp(winner)) winner = definition
133
+ }
134
+ return winner
135
+ }
136
+
137
+ export function markDefinitionTombstoned<T extends MutableDefinitionScopeRecord>(definition: T, now = new Date()) {
138
+ definition.isActive = false
139
+ definition.deletedAt = definition.deletedAt ?? now
140
+ definition.updatedAt = now
141
+ return definition
142
+ }
143
+
144
+ function cloneConfigJson(configJson: unknown) {
145
+ if (configJson == null) return configJson
146
+ return JSON.parse(JSON.stringify(configJson))
147
+ }
148
+
149
+ export function createScopedDefinitionTombstone(
150
+ em: Pick<EntityManager, 'create'>,
151
+ source: {
152
+ entityId: string
153
+ key: string
154
+ kind: string
155
+ configJson?: unknown
156
+ },
157
+ scope: DefinitionMutationScope,
158
+ now = new Date(),
159
+ ) {
160
+ return em.create(CustomFieldDef, {
161
+ entityId: source.entityId,
162
+ key: source.key,
163
+ kind: source.kind,
164
+ configJson: cloneConfigJson(source.configJson) ?? {},
165
+ organizationId: scope.organizationId,
166
+ tenantId: scope.tenantId,
167
+ isActive: false,
168
+ createdAt: now,
169
+ updatedAt: now,
170
+ deletedAt: now,
171
+ })
172
+ }