@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.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/business_rules/lib/value-resolver.js +7 -0
  3. package/dist/modules/business_rules/lib/value-resolver.js.map +2 -2
  4. package/dist/modules/currencies/commands/currencies.js +22 -8
  5. package/dist/modules/currencies/commands/currencies.js.map +2 -2
  6. package/dist/modules/currencies/commands/exchange-rates.js +22 -8
  7. package/dist/modules/currencies/commands/exchange-rates.js.map +2 -2
  8. package/dist/modules/currencies/commands/scope.js +22 -0
  9. package/dist/modules/currencies/commands/scope.js.map +7 -0
  10. package/dist/modules/customers/components/detail/ActivitiesSection.js +5 -4
  11. package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
  12. package/dist/modules/customers/components/detail/ActivityHistorySection.js +5 -4
  13. package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
  14. package/dist/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.js +6 -0
  15. package/dist/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.js.map +2 -2
  16. package/dist/modules/dictionaries/api/[dictionaryId]/entries/reorder/route.js +3 -0
  17. package/dist/modules/dictionaries/api/[dictionaryId]/entries/reorder/route.js.map +2 -2
  18. package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js +6 -0
  19. package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js.map +2 -2
  20. package/dist/modules/dictionaries/api/[dictionaryId]/entries/set-default/route.js +3 -0
  21. package/dist/modules/dictionaries/api/[dictionaryId]/entries/set-default/route.js.map +2 -2
  22. package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js +107 -92
  23. package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js.map +2 -2
  24. package/dist/modules/feature_toggles/api/global/route.js +6 -0
  25. package/dist/modules/feature_toggles/api/global/route.js.map +2 -2
  26. package/dist/modules/feature_toggles/commands/global.js +9 -9
  27. package/dist/modules/feature_toggles/commands/global.js.map +2 -2
  28. package/dist/modules/query_index/lib/subscriber-scope.js +63 -10
  29. package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
  30. package/dist/modules/query_index/subscribers/delete_one.js +20 -6
  31. package/dist/modules/query_index/subscribers/delete_one.js.map +2 -2
  32. package/dist/modules/query_index/subscribers/upsert_one.js +8 -3
  33. package/dist/modules/query_index/subscribers/upsert_one.js.map +2 -2
  34. package/dist/modules/workflows/api/definitions/[id]/customize/route.js +2 -0
  35. package/dist/modules/workflows/api/definitions/[id]/customize/route.js.map +2 -2
  36. package/dist/modules/workflows/api/definitions/[id]/reset-to-code/route.js +4 -0
  37. package/dist/modules/workflows/api/definitions/[id]/reset-to-code/route.js.map +2 -2
  38. package/package.json +7 -7
  39. package/src/modules/business_rules/lib/value-resolver.ts +16 -0
  40. package/src/modules/currencies/commands/currencies.ts +26 -8
  41. package/src/modules/currencies/commands/exchange-rates.ts +26 -8
  42. package/src/modules/currencies/commands/scope.ts +32 -0
  43. package/src/modules/customers/components/detail/ActivitiesSection.tsx +10 -6
  44. package/src/modules/customers/components/detail/ActivityHistorySection.tsx +6 -3
  45. package/src/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.ts +6 -0
  46. package/src/modules/dictionaries/api/[dictionaryId]/entries/reorder/route.ts +3 -0
  47. package/src/modules/dictionaries/api/[dictionaryId]/entries/route.ts +6 -0
  48. package/src/modules/dictionaries/api/[dictionaryId]/entries/set-default/route.ts +3 -0
  49. package/src/modules/dictionaries/components/DictionaryEntriesEditor.tsx +15 -4
  50. package/src/modules/feature_toggles/api/global/route.ts +7 -1
  51. package/src/modules/feature_toggles/commands/global.ts +9 -12
  52. package/src/modules/query_index/lib/subscriber-scope.ts +98 -14
  53. package/src/modules/query_index/subscribers/delete_one.ts +21 -10
  54. package/src/modules/query_index/subscribers/upsert_one.ts +8 -3
  55. package/src/modules/workflows/AGENTS.md +22 -0
  56. package/src/modules/workflows/api/definitions/[id]/customize/route.ts +9 -0
  57. 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="dictionary-entry-value-error"
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
- tenantField: "tenantId",
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: ctx.auth?.tenantId ?? null,
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, ctx),
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, ctx),
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, ctx),
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, ctx),
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, ctx),
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, ctx),
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, ctx),
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 { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
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
- rowScope: QueryIndexScope | null
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 async function loadQueryIndexRowScope(
47
+ export function resolveQueryIndexSourceMetadata(
31
48
  em: EntityManager,
32
49
  entityType: string,
33
- recordId: string
34
- ): Promise<QueryIndexScope | null> {
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 table = resolveEntityTableName(em, entityType)
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(['organization_id' as any, 'tenant_id' as any])
92
+ .selectFrom(source.table as any)
93
+ .select(columns as any)
40
94
  .where('id' as any, '=', recordId)
41
- .executeTakeFirst() as { organization_id: string | null; tenant_id: string | null } | undefined
95
+ .executeTakeFirst() as Record<string, string | null | undefined> | undefined
42
96
 
43
97
  if (!row) {
44
- return null
98
+ return { kind: 'missing' }
45
99
  }
46
100
 
47
101
  return {
48
- organizationId: row.organization_id ?? null,
49
- tenantId: row.tenant_id ?? null,
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
- rowScope,
116
+ sourceScope,
60
117
  } = input
61
118
 
62
- if (!rowScope) {
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 { loadQueryIndexRowScope, resolveQueryIndexRecordScope } from '../lib/subscriber-scope'
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 rowScope = await loadQueryIndexRowScope(em, entityType, recordId).catch(() => null)
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
- rowScope,
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
- const table = resolveEntityTableName(em, entityType)
61
- const row = await db
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
- .where('organization_id' as any, organizationId === null ? 'is' : '=', organizationId as any)
66
- .where(sql`tenant_id is not distinct from ${tenantId}`)
67
- .executeTakeFirst() as { deleted_at: Date | null } | undefined
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 { loadQueryIndexRowScope, resolveQueryIndexRecordScope } from '../lib/subscriber-scope'
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 rowScope = await loadQueryIndexRowScope(em, entityType, recordId).catch(() => null)
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
- rowScope,
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 ?? '',