@open-mercato/core 0.6.7-develop.6652.1.3ab996caba → 0.6.7-develop.6660.1.90e1e2eef6
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/modules/business_rules/lib/value-resolver.js +7 -0
- package/dist/modules/business_rules/lib/value-resolver.js.map +2 -2
- package/dist/modules/currencies/commands/currencies.js +22 -8
- package/dist/modules/currencies/commands/currencies.js.map +2 -2
- package/dist/modules/currencies/commands/exchange-rates.js +22 -8
- package/dist/modules/currencies/commands/exchange-rates.js.map +2 -2
- package/dist/modules/currencies/commands/scope.js +22 -0
- package/dist/modules/currencies/commands/scope.js.map +7 -0
- package/dist/modules/customers/components/detail/ActivitiesSection.js +5 -4
- package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityHistorySection.js +5 -4
- package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.js +6 -0
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.js.map +2 -2
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/reorder/route.js +3 -0
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/reorder/route.js.map +2 -2
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js +6 -0
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js.map +2 -2
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/set-default/route.js +3 -0
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/set-default/route.js.map +2 -2
- package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js +107 -92
- package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js.map +2 -2
- package/dist/modules/feature_toggles/api/global/route.js +6 -0
- package/dist/modules/feature_toggles/api/global/route.js.map +2 -2
- package/dist/modules/feature_toggles/commands/global.js +9 -9
- package/dist/modules/feature_toggles/commands/global.js.map +2 -2
- package/dist/modules/query_index/lib/subscriber-scope.js +63 -10
- package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
- package/dist/modules/query_index/subscribers/delete_one.js +20 -6
- package/dist/modules/query_index/subscribers/delete_one.js.map +2 -2
- package/dist/modules/query_index/subscribers/upsert_one.js +8 -3
- package/dist/modules/query_index/subscribers/upsert_one.js.map +2 -2
- package/dist/modules/workflows/api/definitions/[id]/customize/route.js +2 -0
- package/dist/modules/workflows/api/definitions/[id]/customize/route.js.map +2 -2
- package/dist/modules/workflows/api/definitions/[id]/reset-to-code/route.js +4 -0
- package/dist/modules/workflows/api/definitions/[id]/reset-to-code/route.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/business_rules/lib/value-resolver.ts +16 -0
- package/src/modules/currencies/commands/currencies.ts +26 -8
- package/src/modules/currencies/commands/exchange-rates.ts +26 -8
- package/src/modules/currencies/commands/scope.ts +32 -0
- package/src/modules/customers/components/detail/ActivitiesSection.tsx +10 -6
- package/src/modules/customers/components/detail/ActivityHistorySection.tsx +6 -3
- package/src/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.ts +6 -0
- package/src/modules/dictionaries/api/[dictionaryId]/entries/reorder/route.ts +3 -0
- package/src/modules/dictionaries/api/[dictionaryId]/entries/route.ts +6 -0
- package/src/modules/dictionaries/api/[dictionaryId]/entries/set-default/route.ts +3 -0
- package/src/modules/dictionaries/components/DictionaryEntriesEditor.tsx +15 -4
- package/src/modules/feature_toggles/api/global/route.ts +7 -1
- package/src/modules/feature_toggles/commands/global.ts +9 -12
- package/src/modules/query_index/lib/subscriber-scope.ts +98 -14
- package/src/modules/query_index/subscribers/delete_one.ts +21 -10
- package/src/modules/query_index/subscribers/upsert_one.ts +8 -3
- package/src/modules/workflows/AGENTS.md +22 -0
- package/src/modules/workflows/api/definitions/[id]/customize/route.ts +9 -0
- package/src/modules/workflows/api/definitions/[id]/reset-to-code/route.ts +7 -0
|
@@ -130,6 +130,9 @@ export async function POST(req: Request, ctx: { params?: { dictionaryId?: string
|
|
|
130
130
|
if (isCrudHttpError(err)) {
|
|
131
131
|
return NextResponse.json(err.body, { status: err.status })
|
|
132
132
|
}
|
|
133
|
+
if (err instanceof z.ZodError) {
|
|
134
|
+
return NextResponse.json({ error: 'Invalid input', details: err.issues }, { status: 400 })
|
|
135
|
+
}
|
|
133
136
|
logger.error('Failed to reorder dictionary entries', { err })
|
|
134
137
|
return NextResponse.json({ error: 'Failed to reorder dictionary entries' }, { status: 500 })
|
|
135
138
|
}
|
|
@@ -160,6 +160,9 @@ export async function GET(req: Request, ctx: { params?: { dictionaryId?: string
|
|
|
160
160
|
if (isCrudHttpError(err)) {
|
|
161
161
|
return NextResponse.json(err.body, { status: err.status })
|
|
162
162
|
}
|
|
163
|
+
if (err instanceof z.ZodError) {
|
|
164
|
+
return NextResponse.json({ error: 'Invalid input', details: err.issues }, { status: 400 })
|
|
165
|
+
}
|
|
163
166
|
logger.error('Failed to load dictionary entries', { err })
|
|
164
167
|
return NextResponse.json({ error: 'Failed to load dictionary entries' }, { status: 500 })
|
|
165
168
|
}
|
|
@@ -252,6 +255,9 @@ export async function POST(req: Request, ctx: { params?: { dictionaryId?: string
|
|
|
252
255
|
if (isCrudHttpError(err)) {
|
|
253
256
|
return NextResponse.json(err.body, { status: err.status })
|
|
254
257
|
}
|
|
258
|
+
if (err instanceof z.ZodError) {
|
|
259
|
+
return NextResponse.json({ error: 'Invalid input', details: err.issues }, { status: 400 })
|
|
260
|
+
}
|
|
255
261
|
logger.error('Failed to create dictionary entry', { err })
|
|
256
262
|
return NextResponse.json({ error: 'Failed to create dictionary entry' }, { status: 500 })
|
|
257
263
|
}
|
|
@@ -130,6 +130,9 @@ export async function POST(req: Request, ctx: { params?: { dictionaryId?: string
|
|
|
130
130
|
if (isCrudHttpError(err)) {
|
|
131
131
|
return NextResponse.json(err.body, { status: err.status })
|
|
132
132
|
}
|
|
133
|
+
if (err instanceof z.ZodError) {
|
|
134
|
+
return NextResponse.json({ error: 'Invalid input', details: err.issues }, { status: 400 })
|
|
135
|
+
}
|
|
133
136
|
logger.error('Failed to set default entry', { err })
|
|
134
137
|
return NextResponse.json({ error: 'Failed to set default entry' }, { status: 500 })
|
|
135
138
|
}
|
|
@@ -424,7 +424,15 @@ export function DictionaryEntriesEditor({ dictionaryId, dictionaryName, readOnly
|
|
|
424
424
|
</div>
|
|
425
425
|
|
|
426
426
|
<Dialog open={dialogOpen} onOpenChange={(open) => (!open ? closeDialog() : undefined)}>
|
|
427
|
-
<DialogContent
|
|
427
|
+
<DialogContent
|
|
428
|
+
aria-modal="true"
|
|
429
|
+
onKeyDown={(event) => {
|
|
430
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter' && !isSaving) {
|
|
431
|
+
event.preventDefault()
|
|
432
|
+
void handleSave()
|
|
433
|
+
}
|
|
434
|
+
}}
|
|
435
|
+
>
|
|
428
436
|
<DialogHeader>
|
|
429
437
|
<DialogTitle>
|
|
430
438
|
{formState.id
|
|
@@ -434,11 +442,12 @@ export function DictionaryEntriesEditor({ dictionaryId, dictionaryName, readOnly
|
|
|
434
442
|
</DialogHeader>
|
|
435
443
|
<div className="space-y-4">
|
|
436
444
|
<div className="space-y-2">
|
|
437
|
-
<label className="text-sm font-medium">
|
|
445
|
+
<label htmlFor="dictionary-entry-value" className="text-sm font-medium">
|
|
438
446
|
{t('dictionaries.config.entries.dialog.valueLabel', 'Value')}
|
|
439
447
|
<span className="ml-1 text-destructive">*</span>
|
|
440
448
|
</label>
|
|
441
449
|
<Input
|
|
450
|
+
id="dictionary-entry-value"
|
|
442
451
|
type="text"
|
|
443
452
|
value={formState.value}
|
|
444
453
|
onChange={(event) => {
|
|
@@ -448,8 +457,9 @@ export function DictionaryEntriesEditor({ dictionaryId, dictionaryName, readOnly
|
|
|
448
457
|
setErrors((prev) => ({ ...prev, value: undefined }))
|
|
449
458
|
}
|
|
450
459
|
}}
|
|
460
|
+
aria-required="true"
|
|
451
461
|
aria-invalid={errors.value ? 'true' : 'false'}
|
|
452
|
-
aria-describedby=
|
|
462
|
+
aria-describedby={errors.value ? 'dictionary-entry-value-error' : undefined}
|
|
453
463
|
/>
|
|
454
464
|
{errors.value ? (
|
|
455
465
|
<p id="dictionary-entry-value-error" className="text-xs text-destructive">
|
|
@@ -458,10 +468,11 @@ export function DictionaryEntriesEditor({ dictionaryId, dictionaryName, readOnly
|
|
|
458
468
|
) : null}
|
|
459
469
|
</div>
|
|
460
470
|
<div className="space-y-2">
|
|
461
|
-
<label className="text-sm font-medium">
|
|
471
|
+
<label htmlFor="dictionary-entry-label" className="text-sm font-medium">
|
|
462
472
|
{t('dictionaries.config.entries.dialog.labelLabel', 'Label')}
|
|
463
473
|
</label>
|
|
464
474
|
<Input
|
|
475
|
+
id="dictionary-entry-label"
|
|
465
476
|
type="text"
|
|
466
477
|
value={formState.label}
|
|
467
478
|
onChange={(event) => setFormState((prev) => ({ ...prev, label: event.target.value }))}
|
|
@@ -103,13 +103,19 @@ const crud = makeCrudRoute({
|
|
|
103
103
|
entity: FeatureToggle,
|
|
104
104
|
idField: 'id',
|
|
105
105
|
orgField: null,
|
|
106
|
-
|
|
106
|
+
// The query engine requires the caller's tenant context even when the
|
|
107
|
+
// underlying entity is global; the list below explicitly disables its
|
|
108
|
+
// automatic tenant predicate.
|
|
109
|
+
tenantField: 'tenantId',
|
|
107
110
|
softDeleteField: 'deletedAt'
|
|
108
111
|
},
|
|
109
112
|
indexer: { entityType: E.feature_toggles.feature_toggle },
|
|
110
113
|
list: {
|
|
111
114
|
schema: listQuerySchema,
|
|
112
115
|
entityId: E.feature_toggles.feature_toggle,
|
|
116
|
+
// FeatureToggle rows and their query-index projections are global
|
|
117
|
+
// (null/null scope), so filtering them by the actor's tenant hides them.
|
|
118
|
+
omitAutomaticTenantOrgScope: true,
|
|
113
119
|
fields: listFields,
|
|
114
120
|
sortFieldMap: {
|
|
115
121
|
id: 'id',
|
|
@@ -52,14 +52,11 @@ const featureToggleCrudIndexer = { entityType: E.feature_toggles.feature_toggle
|
|
|
52
52
|
|
|
53
53
|
const FEATURE_TOGGLE_LOCK_RESOURCE_KIND = 'feature_toggles.feature_toggle'
|
|
54
54
|
|
|
55
|
-
function featureToggleIdentifiers(
|
|
56
|
-
toggle: FeatureToggle | ToggleSnapshot,
|
|
57
|
-
ctx: { auth?: { tenantId?: string | null } | null },
|
|
58
|
-
) {
|
|
55
|
+
function featureToggleIdentifiers(toggle: FeatureToggle | ToggleSnapshot) {
|
|
59
56
|
return {
|
|
60
57
|
id: toggle.id,
|
|
61
58
|
organizationId: null,
|
|
62
|
-
tenantId:
|
|
59
|
+
tenantId: null,
|
|
63
60
|
}
|
|
64
61
|
}
|
|
65
62
|
|
|
@@ -113,7 +110,7 @@ const createToggleCommand: CommandHandler<ToggleCreateInput, { toggleId: string
|
|
|
113
110
|
dataEngine,
|
|
114
111
|
action: 'created',
|
|
115
112
|
entity: toggle,
|
|
116
|
-
identifiers: featureToggleIdentifiers(toggle
|
|
113
|
+
identifiers: featureToggleIdentifiers(toggle),
|
|
117
114
|
syncOrigin: ctx.syncOrigin,
|
|
118
115
|
indexer: featureToggleCrudIndexer,
|
|
119
116
|
})
|
|
@@ -157,7 +154,7 @@ const createToggleCommand: CommandHandler<ToggleCreateInput, { toggleId: string
|
|
|
157
154
|
dataEngine,
|
|
158
155
|
action: 'deleted',
|
|
159
156
|
entity: toggle,
|
|
160
|
-
identifiers: featureToggleIdentifiers(toggle
|
|
157
|
+
identifiers: featureToggleIdentifiers(toggle),
|
|
161
158
|
syncOrigin: ctx.syncOrigin,
|
|
162
159
|
indexer: featureToggleCrudIndexer,
|
|
163
160
|
})
|
|
@@ -196,7 +193,7 @@ const createToggleCommand: CommandHandler<ToggleCreateInput, { toggleId: string
|
|
|
196
193
|
dataEngine,
|
|
197
194
|
action: 'created',
|
|
198
195
|
entity: toggle,
|
|
199
|
-
identifiers: featureToggleIdentifiers(toggle
|
|
196
|
+
identifiers: featureToggleIdentifiers(toggle),
|
|
200
197
|
syncOrigin: ctx.syncOrigin,
|
|
201
198
|
indexer: featureToggleCrudIndexer,
|
|
202
199
|
})
|
|
@@ -245,7 +242,7 @@ const updateToggleCommand: CommandHandler<ToggleUpdateInput, { toggleId: string
|
|
|
245
242
|
dataEngine,
|
|
246
243
|
action: 'updated',
|
|
247
244
|
entity: toggle,
|
|
248
|
-
identifiers: featureToggleIdentifiers(toggle
|
|
245
|
+
identifiers: featureToggleIdentifiers(toggle),
|
|
249
246
|
syncOrigin: ctx.syncOrigin,
|
|
250
247
|
indexer: featureToggleCrudIndexer,
|
|
251
248
|
})
|
|
@@ -328,7 +325,7 @@ const updateToggleCommand: CommandHandler<ToggleUpdateInput, { toggleId: string
|
|
|
328
325
|
dataEngine,
|
|
329
326
|
action: 'updated',
|
|
330
327
|
entity: toggle,
|
|
331
|
-
identifiers: featureToggleIdentifiers(toggle
|
|
328
|
+
identifiers: featureToggleIdentifiers(toggle),
|
|
332
329
|
syncOrigin: ctx.syncOrigin,
|
|
333
330
|
indexer: featureToggleCrudIndexer,
|
|
334
331
|
})
|
|
@@ -369,7 +366,7 @@ const deleteToggleCommand: CommandHandler<{ body?: Record<string, unknown>; quer
|
|
|
369
366
|
dataEngine,
|
|
370
367
|
action: 'deleted',
|
|
371
368
|
entity: toggle,
|
|
372
|
-
identifiers: featureToggleIdentifiers(toggle
|
|
369
|
+
identifiers: featureToggleIdentifiers(toggle),
|
|
373
370
|
syncOrigin: ctx.syncOrigin,
|
|
374
371
|
indexer: featureToggleCrudIndexer,
|
|
375
372
|
})
|
|
@@ -443,7 +440,7 @@ const deleteToggleCommand: CommandHandler<{ body?: Record<string, unknown>; quer
|
|
|
443
440
|
dataEngine,
|
|
444
441
|
action: 'updated',
|
|
445
442
|
entity: toggle,
|
|
446
|
-
identifiers: featureToggleIdentifiers(toggle
|
|
443
|
+
identifiers: featureToggleIdentifiers(toggle),
|
|
447
444
|
syncOrigin: ctx.syncOrigin,
|
|
448
445
|
indexer: featureToggleCrudIndexer,
|
|
449
446
|
})
|
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
-
import {
|
|
2
|
+
import { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'
|
|
3
|
+
import { resolveRegisteredEntityTableName } from '@open-mercato/shared/lib/query/engine'
|
|
3
4
|
|
|
4
5
|
export type QueryIndexScope = {
|
|
5
6
|
tenantId: string | null
|
|
6
7
|
organizationId: string | null
|
|
7
8
|
}
|
|
8
9
|
|
|
10
|
+
export type QueryIndexSourceMetadata = {
|
|
11
|
+
table: string
|
|
12
|
+
organizationColumn: string | null
|
|
13
|
+
tenantColumn: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type QueryIndexSourceScope =
|
|
17
|
+
| { kind: 'global' }
|
|
18
|
+
| { kind: 'missing' }
|
|
19
|
+
| { kind: 'row'; scope: QueryIndexScope }
|
|
20
|
+
|
|
21
|
+
type QueryIndexEntityMetadata = {
|
|
22
|
+
tableName?: unknown
|
|
23
|
+
properties?: Record<string, { fieldNames?: unknown }>
|
|
24
|
+
}
|
|
25
|
+
|
|
9
26
|
export class QueryIndexScopeError extends Error {
|
|
10
27
|
constructor(message: string) {
|
|
11
28
|
super(message)
|
|
@@ -18,7 +35,7 @@ type ResolveRecordScopeInput = {
|
|
|
18
35
|
payloadOrganizationId: string | null | undefined
|
|
19
36
|
hasPayloadTenantId: boolean
|
|
20
37
|
hasPayloadOrganizationId: boolean
|
|
21
|
-
|
|
38
|
+
sourceScope: QueryIndexSourceScope
|
|
22
39
|
}
|
|
23
40
|
|
|
24
41
|
type ResolveReindexScopeInput = {
|
|
@@ -27,26 +44,66 @@ type ResolveReindexScopeInput = {
|
|
|
27
44
|
allowAllTenants?: boolean
|
|
28
45
|
}
|
|
29
46
|
|
|
30
|
-
export
|
|
47
|
+
export function resolveQueryIndexSourceMetadata(
|
|
31
48
|
em: EntityManager,
|
|
32
49
|
entityType: string,
|
|
33
|
-
|
|
34
|
-
)
|
|
50
|
+
): QueryIndexSourceMetadata {
|
|
51
|
+
const registeredEntityIds = Object.values(getEntityIds(false)).flatMap((moduleEntities) => Object.values(moduleEntities ?? {}))
|
|
52
|
+
if (!registeredEntityIds.includes(entityType)) {
|
|
53
|
+
throw new QueryIndexScopeError(`Query index entity type is not registered: ${entityType}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const table = resolveRegisteredEntityTableName(em, entityType as never)
|
|
57
|
+
if (!table) {
|
|
58
|
+
throw new QueryIndexScopeError(`Query index entity type has no registered ORM table: ${entityType}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const registry = em.getMetadata?.()
|
|
62
|
+
const allMetadataRaw = typeof registry?.getAll === 'function' ? registry.getAll() : null
|
|
63
|
+
const allMetadata: QueryIndexEntityMetadata[] = Array.isArray(allMetadataRaw)
|
|
64
|
+
? allMetadataRaw as QueryIndexEntityMetadata[]
|
|
65
|
+
: allMetadataRaw instanceof Map
|
|
66
|
+
? Array.from(allMetadataRaw.values()) as QueryIndexEntityMetadata[]
|
|
67
|
+
: Object.values(allMetadataRaw ?? {}) as QueryIndexEntityMetadata[]
|
|
68
|
+
const metadata = allMetadata.find((candidate) => String(candidate.tableName ?? '') === table)
|
|
69
|
+
if (!metadata) {
|
|
70
|
+
throw new QueryIndexScopeError(`Query index entity metadata was not found for table: ${table}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
table,
|
|
75
|
+
organizationColumn: resolveScopeColumn(metadata, 'organizationId', table),
|
|
76
|
+
tenantColumn: resolveScopeColumn(metadata, 'tenantId', table),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function loadQueryIndexRowScope(
|
|
81
|
+
em: EntityManager,
|
|
82
|
+
source: QueryIndexSourceMetadata,
|
|
83
|
+
recordId: string,
|
|
84
|
+
): Promise<QueryIndexSourceScope> {
|
|
85
|
+
if (!source.organizationColumn && !source.tenantColumn) {
|
|
86
|
+
return { kind: 'global' }
|
|
87
|
+
}
|
|
88
|
+
|
|
35
89
|
const db = em.getKysely<any>()
|
|
36
|
-
const
|
|
90
|
+
const columns = [source.organizationColumn, source.tenantColumn].filter((column): column is string => column !== null)
|
|
37
91
|
const row = await db
|
|
38
|
-
.selectFrom(table as any)
|
|
39
|
-
.select(
|
|
92
|
+
.selectFrom(source.table as any)
|
|
93
|
+
.select(columns as any)
|
|
40
94
|
.where('id' as any, '=', recordId)
|
|
41
|
-
.executeTakeFirst() as
|
|
95
|
+
.executeTakeFirst() as Record<string, string | null | undefined> | undefined
|
|
42
96
|
|
|
43
97
|
if (!row) {
|
|
44
|
-
return
|
|
98
|
+
return { kind: 'missing' }
|
|
45
99
|
}
|
|
46
100
|
|
|
47
101
|
return {
|
|
48
|
-
|
|
49
|
-
|
|
102
|
+
kind: 'row',
|
|
103
|
+
scope: {
|
|
104
|
+
organizationId: source.organizationColumn ? row[source.organizationColumn] ?? null : null,
|
|
105
|
+
tenantId: source.tenantColumn ? row[source.tenantColumn] ?? null : null,
|
|
106
|
+
},
|
|
50
107
|
}
|
|
51
108
|
}
|
|
52
109
|
|
|
@@ -56,10 +113,23 @@ export function resolveQueryIndexRecordScope(input: ResolveRecordScopeInput): Qu
|
|
|
56
113
|
payloadOrganizationId,
|
|
57
114
|
hasPayloadTenantId,
|
|
58
115
|
hasPayloadOrganizationId,
|
|
59
|
-
|
|
116
|
+
sourceScope,
|
|
60
117
|
} = input
|
|
61
118
|
|
|
62
|
-
if (
|
|
119
|
+
if (sourceScope.kind === 'global') {
|
|
120
|
+
if (!hasPayloadTenantId || !hasPayloadOrganizationId || payloadTenantId !== null || payloadOrganizationId !== null) {
|
|
121
|
+
throw new QueryIndexScopeError(
|
|
122
|
+
'Query index event for a global entity must explicitly provide tenantId and organizationId as null'
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
tenantId: null,
|
|
128
|
+
organizationId: null,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (sourceScope.kind === 'missing') {
|
|
63
133
|
if (!hasPayloadTenantId || !hasPayloadOrganizationId) {
|
|
64
134
|
throw new QueryIndexScopeError(
|
|
65
135
|
'Query index event is missing tenantId/organizationId and source row scope could not be resolved'
|
|
@@ -72,6 +142,8 @@ export function resolveQueryIndexRecordScope(input: ResolveRecordScopeInput): Qu
|
|
|
72
142
|
}
|
|
73
143
|
}
|
|
74
144
|
|
|
145
|
+
const rowScope = sourceScope.scope
|
|
146
|
+
|
|
75
147
|
if (hasPayloadTenantId && !isSameScopeValue(payloadTenantId, rowScope.tenantId)) {
|
|
76
148
|
throw new QueryIndexScopeError(
|
|
77
149
|
`Query index event tenantId does not match source row scope (payload=${String(payloadTenantId ?? null)}, row=${String(rowScope.tenantId)})`
|
|
@@ -109,3 +181,15 @@ export function resolveQueryIndexReindexScope(input: ResolveReindexScopeInput):
|
|
|
109
181
|
function isSameScopeValue(left: string | null | undefined, right: string | null): boolean {
|
|
110
182
|
return (left ?? null) === right
|
|
111
183
|
}
|
|
184
|
+
|
|
185
|
+
function resolveScopeColumn(metadata: QueryIndexEntityMetadata, property: string, table: string): string | null {
|
|
186
|
+
const scopeProperty = metadata.properties?.[property]
|
|
187
|
+
if (!scopeProperty) return null
|
|
188
|
+
const fieldNames = scopeProperty.fieldNames
|
|
189
|
+
if (!Array.isArray(fieldNames) || fieldNames.length !== 1 || typeof fieldNames[0] !== 'string' || !fieldNames[0]) {
|
|
190
|
+
throw new QueryIndexScopeError(
|
|
191
|
+
`Query index ${property} metadata must map exactly one physical column for table: ${table}`
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
return fieldNames[0]
|
|
195
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
|
|
2
|
-
import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
|
|
3
2
|
import { sql } from 'kysely'
|
|
4
3
|
import { markDeleted } from '../lib/indexer'
|
|
5
4
|
import { applyCoverageAdjustments, createCoverageAdjustments } from '../lib/coverage'
|
|
6
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
loadQueryIndexRowScope,
|
|
7
|
+
resolveQueryIndexRecordScope,
|
|
8
|
+
resolveQueryIndexSourceMetadata,
|
|
9
|
+
} from '../lib/subscriber-scope'
|
|
7
10
|
|
|
8
11
|
export const metadata = { event: 'query_index.delete_one', persistent: false }
|
|
9
12
|
|
|
@@ -40,13 +43,14 @@ export default async function handle(payload: any, ctx: { resolve: <T=any>(name:
|
|
|
40
43
|
try {
|
|
41
44
|
const hasPayloadOrganizationId = Object.prototype.hasOwnProperty.call(payload ?? {}, 'organizationId')
|
|
42
45
|
const hasPayloadTenantId = Object.prototype.hasOwnProperty.call(payload ?? {}, 'tenantId')
|
|
43
|
-
const
|
|
46
|
+
const source = resolveQueryIndexSourceMetadata(em, entityType)
|
|
47
|
+
const sourceScope = await loadQueryIndexRowScope(em, source, recordId)
|
|
44
48
|
const resolvedScope = resolveQueryIndexRecordScope({
|
|
45
49
|
payloadOrganizationId: payload?.organizationId,
|
|
46
50
|
payloadTenantId: payload?.tenantId,
|
|
47
51
|
hasPayloadOrganizationId,
|
|
48
52
|
hasPayloadTenantId,
|
|
49
|
-
|
|
53
|
+
sourceScope,
|
|
50
54
|
})
|
|
51
55
|
organizationId = resolvedScope.organizationId
|
|
52
56
|
tenantId = resolvedScope.tenantId
|
|
@@ -57,14 +61,21 @@ export default async function handle(payload: any, ctx: { resolve: <T=any>(name:
|
|
|
57
61
|
let baseCheckSucceeded = false
|
|
58
62
|
try {
|
|
59
63
|
const db = (em as any).getKysely()
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
.selectFrom(table as any)
|
|
64
|
+
let baseQuery = db
|
|
65
|
+
.selectFrom(source.table as any)
|
|
63
66
|
.select(['deleted_at' as any])
|
|
64
67
|
.where('id' as any, '=', recordId)
|
|
65
|
-
|
|
66
|
-
.where(
|
|
67
|
-
|
|
68
|
+
if (source.organizationColumn) {
|
|
69
|
+
baseQuery = baseQuery.where(
|
|
70
|
+
source.organizationColumn as any,
|
|
71
|
+
organizationId === null ? 'is' : '=',
|
|
72
|
+
organizationId as any,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
if (source.tenantColumn) {
|
|
76
|
+
baseQuery = baseQuery.where(sql`${sql.ref(source.tenantColumn)} is not distinct from ${tenantId}`)
|
|
77
|
+
}
|
|
78
|
+
const row = await baseQuery.executeTakeFirst() as { deleted_at: Date | null } | undefined
|
|
68
79
|
const baseMissing = !row
|
|
69
80
|
const baseDeleted = baseMissing || (row && row.deleted_at != null)
|
|
70
81
|
baseCheckSucceeded = true
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
|
|
2
2
|
import { upsertIndexRow, reindexSearchTokensForRecord } from '../lib/indexer'
|
|
3
3
|
import { applyCoverageAdjustments, createCoverageAdjustments } from '../lib/coverage'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
loadQueryIndexRowScope,
|
|
6
|
+
resolveQueryIndexRecordScope,
|
|
7
|
+
resolveQueryIndexSourceMetadata,
|
|
8
|
+
} from '../lib/subscriber-scope'
|
|
5
9
|
|
|
6
10
|
export const metadata = { event: 'query_index.upsert_one', persistent: false }
|
|
7
11
|
|
|
@@ -25,13 +29,14 @@ export default async function handle(payload: any, ctx: { resolve: <T=any>(name:
|
|
|
25
29
|
try {
|
|
26
30
|
const hasPayloadOrganizationId = Object.prototype.hasOwnProperty.call(payload ?? {}, 'organizationId')
|
|
27
31
|
const hasPayloadTenantId = Object.prototype.hasOwnProperty.call(payload ?? {}, 'tenantId')
|
|
28
|
-
const
|
|
32
|
+
const source = resolveQueryIndexSourceMetadata(em, entityType)
|
|
33
|
+
const sourceScope = await loadQueryIndexRowScope(em, source, recordId)
|
|
29
34
|
const resolvedScope = resolveQueryIndexRecordScope({
|
|
30
35
|
payloadOrganizationId: payload?.organizationId,
|
|
31
36
|
payloadTenantId: payload?.tenantId,
|
|
32
37
|
hasPayloadOrganizationId,
|
|
33
38
|
hasPayloadTenantId,
|
|
34
|
-
|
|
39
|
+
sourceScope,
|
|
35
40
|
})
|
|
36
41
|
organizationId = resolvedScope.organizationId
|
|
37
42
|
tenantId = resolvedScope.tenantId
|
|
@@ -142,6 +142,28 @@ Configure automatic workflow starts from domain events:
|
|
|
142
142
|
5. Configure `contextMapping` to extract event payload into workflow context
|
|
143
143
|
6. Use `debounceMs` and `maxConcurrentInstances` to prevent trigger storms
|
|
144
144
|
|
|
145
|
+
### Trigger Sources And Precedence
|
|
146
|
+
|
|
147
|
+
`loadTriggersForTenant()` (`lib/event-trigger-service.ts`) merges three sources into `UnifiedTrigger`s, each tagged with a `source` discriminator:
|
|
148
|
+
|
|
149
|
+
| `source` | Origin | Notes |
|
|
150
|
+
|----------|--------|-------|
|
|
151
|
+
| `legacy` | `workflow_event_triggers` rows | Backward compatibility with triggers created before triggers were embedded in definitions |
|
|
152
|
+
| `embedded` | `triggers[]` inside a `workflow_definitions` row's `definition` JSONB | What the visual editor and the definitions API write |
|
|
153
|
+
| `code` | `triggers[]` on a code-defined workflow in the in-memory registry (`defineWorkflow`) | Projected by `loadCodeTriggers()`; no DB row required (#4425) |
|
|
154
|
+
|
|
155
|
+
Precedence: **a DB-backed definition wins over its code counterpart.** Any non-deleted `workflow_definitions` row shadows the code projection for the same `workflowId` — including a disabled row, and including a customization whose `triggers[]` was emptied. This preserves `customize` semantics: once an operator materializes a code workflow, the DB row alone decides which triggers are live.
|
|
156
|
+
|
|
157
|
+
MUST invalidate the trigger cache after any write that changes which source owns a workflow's triggers — `loadTriggersForTenant()` caches per tenant/organization for `TRIGGER_CACHE_TTL` (5 min), so without invalidation the wildcard subscriber keeps matching a stale snapshot:
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
import { invalidateTriggerCache } from '../lib/event-trigger-service'
|
|
161
|
+
|
|
162
|
+
if (tenantId) invalidateTriggerCache(tenantId, organizationId ?? undefined)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
This covers definition create/update/delete **and** `POST .../[id]/customize` (code projection → embedded row) and `POST .../[id]/reset-to-code` (embedded row → code projection). Invalidate for the **written row's own** tenant/organization rather than the caller's — `customize` looks an override up by `(workflowId, tenantId)`, so it can revive a row owned by a sibling organization. Omitting `organizationId` clears every organization under the tenant.
|
|
166
|
+
|
|
145
167
|
## Widget Injection
|
|
146
168
|
|
|
147
169
|
The module injects an order-approval widget into the sales module:
|
|
@@ -17,6 +17,7 @@ import { validateCrudMutationGuard, runCrudMutationGuardAfterSuccess } from '@op
|
|
|
17
17
|
import { WorkflowDefinition } from '../../../../data/entities'
|
|
18
18
|
import { serializeWorkflowDefinition } from '../../serialize'
|
|
19
19
|
import { getCodeWorkflow } from '../../../../lib/code-registry'
|
|
20
|
+
import { invalidateTriggerCache } from '../../../../lib/event-trigger-service'
|
|
20
21
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
21
22
|
|
|
22
23
|
const logger = createLogger('workflows')
|
|
@@ -115,6 +116,14 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
|
|
115
116
|
saved = override
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
// Materializing the override moves trigger ownership from the code registry
|
|
120
|
+
// to the new `workflow_definitions` row, so the cached snapshot the wildcard
|
|
121
|
+
// subscriber reads is stale until TRIGGER_CACHE_TTL expires (#4425). Scope
|
|
122
|
+
// the invalidation to the saved row's own organization: reviving an existing
|
|
123
|
+
// override belonging to a sibling organization changes triggers for THAT
|
|
124
|
+
// organization, not for the caller's.
|
|
125
|
+
if (saved.tenantId) invalidateTriggerCache(saved.tenantId, saved.organizationId ?? undefined)
|
|
126
|
+
|
|
118
127
|
if (guardResult?.shouldRunAfterSuccess) {
|
|
119
128
|
await runCrudMutationGuardAfterSuccess(container, {
|
|
120
129
|
tenantId: tenantId ?? '',
|
|
@@ -16,6 +16,7 @@ import { validateCrudMutationGuard, runCrudMutationGuardAfterSuccess } from '@op
|
|
|
16
16
|
import { WorkflowDefinition, WorkflowInstance } from '../../../../data/entities'
|
|
17
17
|
import { serializeCodeWorkflowDefinition } from '../../serialize'
|
|
18
18
|
import { getCodeWorkflow } from '../../../../lib/code-registry'
|
|
19
|
+
import { invalidateTriggerCache } from '../../../../lib/event-trigger-service'
|
|
19
20
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
20
21
|
|
|
21
22
|
const logger = createLogger('workflows')
|
|
@@ -140,6 +141,12 @@ export async function POST(
|
|
|
140
141
|
em.remove(definition)
|
|
141
142
|
await em.flush()
|
|
142
143
|
|
|
144
|
+
// Trigger ownership falls back to the code registry, so the cached snapshot
|
|
145
|
+
// still holds the embedded triggers of a row that no longer exists (#4425).
|
|
146
|
+
if (removedSnapshot.tenantId) {
|
|
147
|
+
invalidateTriggerCache(removedSnapshot.tenantId, removedSnapshot.organizationId ?? undefined)
|
|
148
|
+
}
|
|
149
|
+
|
|
143
150
|
if (guardResult?.shouldRunAfterSuccess) {
|
|
144
151
|
await runCrudMutationGuardAfterSuccess(container, {
|
|
145
152
|
tenantId: tenantId ?? '',
|