@open-mercato/core 0.6.6-develop.6543.1.49fadb4c31 → 0.6.6
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/api_keys/api/keys/route.js +9 -23
- package/dist/modules/api_keys/api/keys/route.js.map +2 -2
- package/dist/modules/api_keys/cli.js +1 -2
- package/dist/modules/api_keys/cli.js.map +2 -2
- package/dist/modules/attachments/api/file/[id]/route.js +7 -9
- package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +7 -9
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
- package/dist/modules/attachments/api/route.js +17 -21
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/auth/api/users/route.js +2 -6
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/auth/cli.js +2 -3
- package/dist/modules/auth/cli.js.map +2 -2
- package/dist/modules/configs/lib/upgrade-actions.js +0 -22
- package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
- package/dist/modules/customer_accounts/widgets/injection-table.js +2 -0
- package/dist/modules/customer_accounts/widgets/injection-table.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +1 -7
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyDetailTabs.js +7 -11
- package/dist/modules/customers/components/detail/CompanyDetailTabs.js.map +2 -2
- package/dist/modules/dashboards/api/roles/widgets/route.js +3 -22
- package/dist/modules/dashboards/api/roles/widgets/route.js.map +2 -2
- package/dist/modules/dashboards/api/users/widgets/route.js +3 -22
- package/dist/modules/dashboards/api/users/widgets/route.js.map +2 -2
- package/dist/modules/dashboards/cli.js +3 -4
- package/dist/modules/dashboards/cli.js.map +2 -2
- package/dist/modules/dashboards/lib/widgetAssignmentScope.js +1 -9
- package/dist/modules/dashboards/lib/widgetAssignmentScope.js.map +2 -2
- package/dist/modules/dictionaries/commands/entries.js +14 -7
- package/dist/modules/dictionaries/commands/entries.js.map +2 -2
- package/dist/modules/dictionaries/commands/entry-operations.js +16 -7
- package/dist/modules/dictionaries/commands/entry-operations.js.map +2 -2
- package/dist/modules/dictionaries/commands/factory.js +3 -9
- package/dist/modules/dictionaries/commands/factory.js.map +2 -2
- package/dist/modules/dictionaries/fields/dictionary.js +41 -56
- package/dist/modules/dictionaries/fields/dictionary.js.map +2 -2
- package/dist/modules/directory/utils/organizationScope.js +3 -10
- package/dist/modules/directory/utils/organizationScope.js.map +2 -2
- package/dist/modules/entities/api/records.js +4 -5
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/entities/backend/entities/system/[entityId]/page.meta.js +0 -1
- package/dist/modules/entities/backend/entities/system/[entityId]/page.meta.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/page.js +49 -49
- package/dist/modules/entities/backend/entities/user/[entityId]/page.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/page.meta.js +0 -1
- package/dist/modules/entities/backend/entities/user/[entityId]/page.meta.js.map +2 -2
- package/dist/modules/entities/cli.js +1 -2
- package/dist/modules/entities/cli.js.map +2 -2
- package/dist/modules/query_index/api/status.js +8 -2
- package/dist/modules/query_index/api/status.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +0 -44
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/commands/returns.js +27 -2
- package/dist/modules/sales/commands/returns.js.map +2 -2
- package/dist/modules/sales/components/documents/AddressesSection.js +2 -6
- package/dist/modules/sales/components/documents/AddressesSection.js.map +2 -2
- package/dist/modules/sales/components/documents/ItemsSection.js +0 -1
- package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/LineItemDialog.js +0 -9
- package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesDocumentForm.js +0 -44
- package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
- package/dist/modules/sales/components/documents/ShipmentDialog.js +38 -59
- package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
- package/dist/modules/sales/lib/shipments/snapshots.js +1 -27
- package/dist/modules/sales/lib/shipments/snapshots.js.map +2 -2
- package/dist/modules/workflows/frontend/checkout-demo/page.js +8 -1
- package/dist/modules/workflows/frontend/checkout-demo/page.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +1 -1
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/lib/find-definition.js +18 -2
- package/dist/modules/workflows/lib/find-definition.js.map +2 -2
- package/dist/modules/workflows/lib/signal-handler.js +3 -2
- package/dist/modules/workflows/lib/signal-handler.js.map +2 -2
- package/dist/modules/workflows/lib/step-handler.js +4 -8
- package/dist/modules/workflows/lib/step-handler.js.map +2 -2
- package/dist/modules/workflows/lib/task-handler.js +3 -5
- package/dist/modules/workflows/lib/task-handler.js.map +2 -2
- package/dist/modules/workflows/lib/timer-handler.js +2 -1
- package/dist/modules/workflows/lib/timer-handler.js.map +2 -2
- package/dist/modules/workflows/lib/transition-handler.js +5 -13
- package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
- package/dist/modules/workflows/lib/workflow-executor.js +11 -9
- package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
- package/dist/modules/workflows/migrations/Migration20260715120000.js +68 -0
- package/dist/modules/workflows/migrations/Migration20260715120000.js.map +7 -0
- package/dist/modules/workflows/migrations/Migration20260716120000.js +24 -0
- package/dist/modules/workflows/migrations/Migration20260716120000.js.map +7 -0
- package/dist/modules/workflows/workflows.js +2 -0
- package/dist/modules/workflows/workflows.js.map +2 -2
- package/package.json +8 -9
- package/src/modules/api_keys/api/keys/route.ts +9 -27
- package/src/modules/api_keys/cli.ts +3 -2
- package/src/modules/attachments/api/file/[id]/route.ts +7 -11
- package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +7 -11
- package/src/modules/attachments/api/route.ts +21 -26
- package/src/modules/auth/api/users/route.ts +2 -11
- package/src/modules/auth/cli.ts +2 -3
- package/src/modules/configs/i18n/de.json +1 -5
- package/src/modules/configs/i18n/en.json +1 -5
- package/src/modules/configs/i18n/es.json +1 -5
- package/src/modules/configs/i18n/pl.json +1 -5
- package/src/modules/configs/lib/upgrade-actions.ts +0 -24
- package/src/modules/customer_accounts/AGENTS.md +6 -8
- package/src/modules/customer_accounts/widgets/injection-table.ts +2 -0
- package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +1 -11
- package/src/modules/customers/components/detail/CompanyDetailTabs.tsx +7 -13
- package/src/modules/dashboards/api/roles/widgets/route.ts +3 -22
- package/src/modules/dashboards/api/users/widgets/route.ts +3 -22
- package/src/modules/dashboards/cli.ts +9 -4
- package/src/modules/dashboards/lib/widgetAssignmentScope.ts +0 -18
- package/src/modules/dictionaries/commands/entries.ts +16 -7
- package/src/modules/dictionaries/commands/entry-operations.ts +18 -8
- package/src/modules/dictionaries/commands/factory.ts +2 -8
- package/src/modules/dictionaries/fields/dictionary.tsx +32 -55
- package/src/modules/dictionaries/i18n/de.json +0 -2
- package/src/modules/dictionaries/i18n/en.json +0 -2
- package/src/modules/dictionaries/i18n/es.json +0 -2
- package/src/modules/dictionaries/i18n/pl.json +0 -2
- package/src/modules/directory/utils/organizationScope.ts +2 -36
- package/src/modules/entities/api/records.ts +7 -5
- package/src/modules/entities/backend/entities/system/[entityId]/page.meta.ts +0 -1
- package/src/modules/entities/backend/entities/user/[entityId]/page.meta.ts +0 -1
- package/src/modules/entities/backend/entities/user/[entityId]/page.tsx +49 -51
- package/src/modules/entities/cli.ts +1 -2
- package/src/modules/entities/i18n/de.json +0 -34
- package/src/modules/entities/i18n/en.json +0 -34
- package/src/modules/entities/i18n/es.json +0 -34
- package/src/modules/entities/i18n/pl.json +0 -34
- package/src/modules/query_index/api/status.ts +8 -2
- package/src/modules/sales/commands/documents.ts +0 -65
- package/src/modules/sales/commands/returns.ts +32 -2
- package/src/modules/sales/components/documents/AddressesSection.tsx +2 -6
- package/src/modules/sales/components/documents/ItemsSection.tsx +0 -5
- package/src/modules/sales/components/documents/LineItemDialog.tsx +0 -10
- package/src/modules/sales/components/documents/SalesDocumentForm.tsx +0 -46
- package/src/modules/sales/components/documents/ShipmentDialog.tsx +40 -65
- package/src/modules/sales/i18n/de.json +0 -4
- package/src/modules/sales/i18n/en.json +0 -4
- package/src/modules/sales/i18n/es.json +0 -4
- package/src/modules/sales/i18n/pl.json +0 -4
- package/src/modules/sales/lib/shipments/snapshots.ts +0 -30
- package/src/modules/workflows/frontend/checkout-demo/page.tsx +12 -1
- package/src/modules/workflows/lib/activity-executor.ts +22 -2
- package/src/modules/workflows/lib/find-definition.ts +53 -1
- package/src/modules/workflows/lib/signal-handler.ts +5 -4
- package/src/modules/workflows/lib/step-handler.ts +4 -8
- package/src/modules/workflows/lib/task-handler.ts +2 -4
- package/src/modules/workflows/lib/timer-handler.ts +3 -2
- package/src/modules/workflows/lib/transition-handler.ts +5 -13
- package/src/modules/workflows/lib/workflow-executor.ts +11 -8
- package/src/modules/workflows/migrations/Migration20260715120000.ts +114 -0
- package/src/modules/workflows/migrations/Migration20260716120000.ts +57 -0
- package/src/modules/workflows/workflows.ts +2 -0
- package/dist/modules/attachments/lib/reconcileOrganization.js +0 -110
- package/dist/modules/attachments/lib/reconcileOrganization.js.map +0 -7
- package/dist/modules/attachments/lib/requestScope.js +0 -10
- package/dist/modules/attachments/lib/requestScope.js.map +0 -7
- package/dist/modules/customers/components/detail/useDealsAccess.js +0 -16
- package/dist/modules/customers/components/detail/useDealsAccess.js.map +0 -7
- package/src/modules/attachments/lib/reconcileOrganization.ts +0 -188
- package/src/modules/attachments/lib/requestScope.ts +0 -27
- package/src/modules/customers/components/detail/useDealsAccess.ts +0 -19
|
@@ -929,8 +929,28 @@ export async function executeCallApi(
|
|
|
929
929
|
// 3. Import the one-time API key helper
|
|
930
930
|
const { withOnetimeApiKey } = await import('../../api_keys/services/apiKeyService')
|
|
931
931
|
|
|
932
|
-
// 4.
|
|
933
|
-
|
|
932
|
+
// 4. Create the one-time API key on an EntityManager that is fully detached
|
|
933
|
+
// from the surrounding request/transaction context.
|
|
934
|
+
//
|
|
935
|
+
// CALL_API runs inside `workflowExecutor.executeWorkflow()`, which wraps
|
|
936
|
+
// the whole execution in `em.transactional(...)`. The request EM is forked
|
|
937
|
+
// with `useContext: true`, so while that transaction is open, EVERY
|
|
938
|
+
// operation on the container's `em` — including this API key's
|
|
939
|
+
// persist/flush — is transparently redirected to the uncommitted
|
|
940
|
+
// transaction fork (MikroORM `getContext()` → `TransactionContext`). The
|
|
941
|
+
// key would therefore stay invisible until the transaction commits, but
|
|
942
|
+
// the commit cannot happen until this activity returns. The outbound
|
|
943
|
+
// self-authenticated `fetch` below opens a SEPARATE DB connection that
|
|
944
|
+
// cannot see the uncommitted row, so the internal API responds `401` and
|
|
945
|
+
// the activity fails (issue #4202).
|
|
946
|
+
//
|
|
947
|
+
// Forking with `useContext: false` (matching the query_index/webhooks
|
|
948
|
+
// isolated-EM convention) gives the key its own pooled connection with
|
|
949
|
+
// autocommit, so it is committed and visible to the internal request
|
|
950
|
+
// immediately.
|
|
951
|
+
const apiKeyEm = container
|
|
952
|
+
.resolve<PostgreSqlEntityManager>('em')
|
|
953
|
+
.fork({ clear: true, freshEventManager: true, useContext: false }) as PostgreSqlEntityManager
|
|
934
954
|
|
|
935
955
|
// 5. Resolve the roles that the one-time API key will inherit.
|
|
936
956
|
//
|
|
@@ -80,9 +80,17 @@ export async function findWorkflowDefinition(
|
|
|
80
80
|
// so disabled code workflows aren't silently executable.
|
|
81
81
|
if (version === undefined && !codeDef.enabled) return null
|
|
82
82
|
|
|
83
|
+
return buildVirtualCodeDefinition(codeDef, tenantId, organizationId)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildVirtualCodeDefinition(
|
|
87
|
+
codeDef: NonNullable<ReturnType<typeof getCodeWorkflow>>,
|
|
88
|
+
tenantId: string,
|
|
89
|
+
organizationId: string,
|
|
90
|
+
): WorkflowDefinition {
|
|
83
91
|
// Construct a virtual WorkflowDefinition object (not persisted)
|
|
84
92
|
const virtual = new WorkflowDefinition()
|
|
85
|
-
virtual.id = codeWorkflowUuid(workflowId)
|
|
93
|
+
virtual.id = codeWorkflowUuid(codeDef.workflowId)
|
|
86
94
|
virtual.workflowId = codeDef.workflowId
|
|
87
95
|
virtual.workflowName = codeDef.workflowName
|
|
88
96
|
virtual.description = codeDef.description ?? null
|
|
@@ -98,3 +106,47 @@ export async function findWorkflowDefinition(
|
|
|
98
106
|
|
|
99
107
|
return virtual
|
|
100
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve a virtual code definition for an already-started instance whose
|
|
112
|
+
* `definitionId` does not exist in the database.
|
|
113
|
+
*
|
|
114
|
+
* Instances started from a virtual code definition persist the deterministic
|
|
115
|
+
* `codeWorkflowUuid(workflowId)` as their `definitionId`. Runtime handlers
|
|
116
|
+
* (step/transition/signal/timer/task) load definitions by that id, so without
|
|
117
|
+
* this fallback an unpersisted code workflow starts but can never advance.
|
|
118
|
+
*
|
|
119
|
+
* The UUID equality gate ensures the fallback never substitutes the code
|
|
120
|
+
* definition for a hard-deleted persisted row: those rows have their own
|
|
121
|
+
* random UUIDs that can never match the deterministic code UUID.
|
|
122
|
+
*/
|
|
123
|
+
export function resolveCodeDefinitionForInstance(instance: {
|
|
124
|
+
definitionId: string
|
|
125
|
+
workflowId: string
|
|
126
|
+
tenantId: string
|
|
127
|
+
organizationId: string
|
|
128
|
+
}): WorkflowDefinition | null {
|
|
129
|
+
const codeDef = getCodeWorkflow(instance.workflowId)
|
|
130
|
+
if (!codeDef) return null
|
|
131
|
+
if (codeWorkflowUuid(instance.workflowId) !== instance.definitionId) return null
|
|
132
|
+
return buildVirtualCodeDefinition(codeDef, instance.tenantId, instance.organizationId)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Find the definition backing a workflow instance: database row by id first,
|
|
137
|
+
* falling back to the in-memory code registry for instances started from an
|
|
138
|
+
* unpersisted code definition.
|
|
139
|
+
*/
|
|
140
|
+
export async function findDefinitionForInstance(
|
|
141
|
+
em: EntityManager,
|
|
142
|
+
instance: {
|
|
143
|
+
definitionId: string
|
|
144
|
+
workflowId: string
|
|
145
|
+
tenantId: string
|
|
146
|
+
organizationId: string
|
|
147
|
+
},
|
|
148
|
+
): Promise<WorkflowDefinition | null> {
|
|
149
|
+
const dbDef = await em.findOne(WorkflowDefinition, { id: instance.definitionId })
|
|
150
|
+
if (dbDef) return dbDef
|
|
151
|
+
return resolveCodeDefinitionForInstance(instance)
|
|
152
|
+
}
|
|
@@ -13,6 +13,7 @@ import type * as eventLoggerModule from './event-logger'
|
|
|
13
13
|
import type * as stepHandlerModule from './step-handler'
|
|
14
14
|
import type * as transitionHandlerModule from './transition-handler'
|
|
15
15
|
import type * as workflowExecutorModule from './workflow-executor'
|
|
16
|
+
import { resolveCodeDefinitionForInstance } from './find-definition'
|
|
16
17
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
17
18
|
|
|
18
19
|
const logger = createLogger('workflows')
|
|
@@ -95,13 +96,13 @@ export async function sendSignal(
|
|
|
95
96
|
// Branch-scoped signal: a FORKED instance routes the signal to the branch
|
|
96
97
|
// paused at a matching WAIT_FOR_SIGNAL step.
|
|
97
98
|
if (instance.status === 'FORKED') {
|
|
98
|
-
const branchDefinition = await findOneWithDecryption(
|
|
99
|
+
const branchDefinition = (await findOneWithDecryption(
|
|
99
100
|
em as PostgreSqlEntityManager,
|
|
100
101
|
WorkflowDefinition,
|
|
101
102
|
{ id: instance.definitionId, tenantId: instance.tenantId, organizationId: instance.organizationId, deletedAt: null },
|
|
102
103
|
undefined,
|
|
103
104
|
{ tenantId: instance.tenantId, organizationId: instance.organizationId },
|
|
104
|
-
)
|
|
105
|
+
)) ?? resolveCodeDefinitionForInstance(instance)
|
|
105
106
|
if (!branchDefinition) {
|
|
106
107
|
throw new SignalError('Workflow definition not found', 'DEFINITION_NOT_FOUND', { definitionId: instance.definitionId })
|
|
107
108
|
}
|
|
@@ -173,7 +174,7 @@ export async function sendSignal(
|
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
// Load workflow definition with tenant/org scope to check current step
|
|
176
|
-
const definition = await findOneWithDecryption(
|
|
177
|
+
const definition = (await findOneWithDecryption(
|
|
177
178
|
em as PostgreSqlEntityManager,
|
|
178
179
|
WorkflowDefinition,
|
|
179
180
|
{
|
|
@@ -184,7 +185,7 @@ export async function sendSignal(
|
|
|
184
185
|
},
|
|
185
186
|
undefined,
|
|
186
187
|
{ tenantId: instance.tenantId, organizationId: instance.organizationId },
|
|
187
|
-
)
|
|
188
|
+
)) ?? resolveCodeDefinitionForInstance(instance)
|
|
188
189
|
if (!definition) {
|
|
189
190
|
throw new SignalError(
|
|
190
191
|
'Workflow definition not found',
|
|
@@ -13,7 +13,6 @@ import { EntityManager } from '@mikro-orm/core'
|
|
|
13
13
|
import {
|
|
14
14
|
WorkflowInstance,
|
|
15
15
|
WorkflowBranchInstance,
|
|
16
|
-
WorkflowDefinition,
|
|
17
16
|
StepInstance,
|
|
18
17
|
UserTask,
|
|
19
18
|
WorkflowEvent,
|
|
@@ -22,6 +21,7 @@ import {
|
|
|
22
21
|
} from '../data/entities'
|
|
23
22
|
import { parseDuration } from './duration'
|
|
24
23
|
import { logWorkflowEvent } from './event-logger'
|
|
24
|
+
import { findDefinitionForInstance } from './find-definition'
|
|
25
25
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
26
26
|
|
|
27
27
|
const logger = createLogger('workflows')
|
|
@@ -76,9 +76,7 @@ export async function enterStep(
|
|
|
76
76
|
branch?: WorkflowBranchInstance | null
|
|
77
77
|
): Promise<StepInstance> {
|
|
78
78
|
// Load workflow definition to get step details
|
|
79
|
-
const definition = await em
|
|
80
|
-
id: instance.definitionId,
|
|
81
|
-
})
|
|
79
|
+
const definition = await findDefinitionForInstance(em, instance)
|
|
82
80
|
|
|
83
81
|
if (!definition) {
|
|
84
82
|
throw new StepExecutionError(
|
|
@@ -213,9 +211,7 @@ export async function executeStep(
|
|
|
213
211
|
const stepInstance = await enterStep(em, instance, stepId, context, branch)
|
|
214
212
|
|
|
215
213
|
// Load workflow definition to get step configuration
|
|
216
|
-
const definition = await em
|
|
217
|
-
id: instance.definitionId,
|
|
218
|
-
})
|
|
214
|
+
const definition = await findDefinitionForInstance(em, instance)
|
|
219
215
|
|
|
220
216
|
if (!definition) {
|
|
221
217
|
throw new StepExecutionError(
|
|
@@ -359,7 +355,7 @@ async function executeStepByType(
|
|
|
359
355
|
{ stepType, stepId: stepDef.stepId }
|
|
360
356
|
)
|
|
361
357
|
}
|
|
362
|
-
const definition = await em
|
|
358
|
+
const definition = await findDefinitionForInstance(em, instance)
|
|
363
359
|
if (!definition) {
|
|
364
360
|
throw new StepExecutionError(
|
|
365
361
|
`Workflow definition not found: ${instance.definitionId}`,
|
|
@@ -17,9 +17,9 @@ import {
|
|
|
17
17
|
WorkflowInstance,
|
|
18
18
|
WorkflowEvent,
|
|
19
19
|
StepInstance,
|
|
20
|
-
WorkflowDefinition,
|
|
21
20
|
} from '../data/entities'
|
|
22
21
|
import { executeWorkflow } from './workflow-executor'
|
|
22
|
+
import { findDefinitionForInstance } from './find-definition'
|
|
23
23
|
import * as stepHandler from './step-handler'
|
|
24
24
|
import * as transitionHandler from './transition-handler'
|
|
25
25
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
@@ -191,9 +191,7 @@ export async function completeUserTask(
|
|
|
191
191
|
const currentStepId = instance.currentStepId
|
|
192
192
|
|
|
193
193
|
// Load workflow definition to find transitions
|
|
194
|
-
const definition = await em
|
|
195
|
-
id: instance.definitionId,
|
|
196
|
-
})
|
|
194
|
+
const definition = await findDefinitionForInstance(em, instance)
|
|
197
195
|
|
|
198
196
|
if (!definition) {
|
|
199
197
|
throw new UserTaskError(
|
|
@@ -14,6 +14,7 @@ import type * as eventLoggerModule from './event-logger'
|
|
|
14
14
|
import type * as stepHandlerModule from './step-handler'
|
|
15
15
|
import type * as transitionHandlerModule from './transition-handler'
|
|
16
16
|
import type * as workflowExecutorModule from './workflow-executor'
|
|
17
|
+
import { resolveCodeDefinitionForInstance } from './find-definition'
|
|
17
18
|
|
|
18
19
|
export interface FireTimerOptions {
|
|
19
20
|
instanceId: string
|
|
@@ -110,7 +111,7 @@ export async function fireTimer(
|
|
|
110
111
|
)
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
const definition = await findOneWithDecryption(
|
|
114
|
+
const definition = (await findOneWithDecryption(
|
|
114
115
|
em as PostgreSqlEntityManager,
|
|
115
116
|
WorkflowDefinition,
|
|
116
117
|
{
|
|
@@ -121,7 +122,7 @@ export async function fireTimer(
|
|
|
121
122
|
},
|
|
122
123
|
undefined,
|
|
123
124
|
{ tenantId: instance.tenantId, organizationId: instance.organizationId },
|
|
124
|
-
)
|
|
125
|
+
)) ?? resolveCodeDefinitionForInstance(instance)
|
|
125
126
|
if (!definition) {
|
|
126
127
|
throw new TimerError(
|
|
127
128
|
'Workflow definition not found',
|
|
@@ -15,7 +15,6 @@ import type { AwilixContainer } from 'awilix'
|
|
|
15
15
|
import type { EventBus } from '@open-mercato/events'
|
|
16
16
|
import {
|
|
17
17
|
WorkflowInstance,
|
|
18
|
-
WorkflowDefinition,
|
|
19
18
|
WorkflowEvent,
|
|
20
19
|
} from '../data/entities'
|
|
21
20
|
import * as ruleEvaluator from '../../business_rules/lib/rule-evaluator'
|
|
@@ -23,6 +22,7 @@ import * as ruleEngine from '../../business_rules/lib/rule-engine'
|
|
|
23
22
|
import * as activityExecutor from './activity-executor'
|
|
24
23
|
import type { ActivityDefinition } from './activity-executor'
|
|
25
24
|
import * as stepHandler from './step-handler'
|
|
25
|
+
import { findDefinitionForInstance } from './find-definition'
|
|
26
26
|
import {
|
|
27
27
|
type ExecutionToken,
|
|
28
28
|
rootToken,
|
|
@@ -116,9 +116,7 @@ export async function evaluateTransition(
|
|
|
116
116
|
|
|
117
117
|
try {
|
|
118
118
|
// Load workflow definition
|
|
119
|
-
const definition = await em
|
|
120
|
-
id: instance.definitionId,
|
|
121
|
-
})
|
|
119
|
+
const definition = await findDefinitionForInstance(em, instance)
|
|
122
120
|
|
|
123
121
|
if (!definition) {
|
|
124
122
|
return {
|
|
@@ -227,9 +225,7 @@ export async function findValidTransitions(
|
|
|
227
225
|
): Promise<TransitionEvaluationResult[]> {
|
|
228
226
|
try {
|
|
229
227
|
// Load workflow definition
|
|
230
|
-
const definition = await em
|
|
231
|
-
id: instance.definitionId,
|
|
232
|
-
})
|
|
228
|
+
const definition = await findDefinitionForInstance(em, instance)
|
|
233
229
|
|
|
234
230
|
if (!definition) {
|
|
235
231
|
return []
|
|
@@ -752,9 +748,7 @@ async function evaluatePreConditions(
|
|
|
752
748
|
): Promise<ruleEngine.RuleEngineResult> {
|
|
753
749
|
try {
|
|
754
750
|
// Load workflow definition to get workflow ID
|
|
755
|
-
const definition = await em
|
|
756
|
-
id: instance.definitionId,
|
|
757
|
-
})
|
|
751
|
+
const definition = await findDefinitionForInstance(em, instance)
|
|
758
752
|
|
|
759
753
|
if (!definition) {
|
|
760
754
|
return {
|
|
@@ -881,9 +875,7 @@ async function evaluatePostConditions(
|
|
|
881
875
|
): Promise<ruleEngine.RuleEngineResult> {
|
|
882
876
|
try {
|
|
883
877
|
// Load workflow definition to get workflow ID
|
|
884
|
-
const definition = await em
|
|
885
|
-
id: instance.definitionId,
|
|
886
|
-
})
|
|
878
|
+
const definition = await findDefinitionForInstance(em, instance)
|
|
887
879
|
|
|
888
880
|
if (!definition) {
|
|
889
881
|
return {
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type WorkflowInstanceStatus,
|
|
20
20
|
} from '../data/entities'
|
|
21
21
|
import { compensateWorkflow } from './compensation-handler'
|
|
22
|
-
import { findWorkflowDefinition } from './find-definition'
|
|
22
|
+
import { findWorkflowDefinition, findDefinitionForInstance } from './find-definition'
|
|
23
23
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
24
24
|
|
|
25
25
|
const logger = createLogger('workflows')
|
|
@@ -278,9 +278,7 @@ export async function executeWorkflow(
|
|
|
278
278
|
)
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
-
const definition = await trx
|
|
282
|
-
id: instance.definitionId,
|
|
283
|
-
})
|
|
281
|
+
const definition = await findDefinitionForInstance(trx, instance)
|
|
284
282
|
|
|
285
283
|
if (!definition) {
|
|
286
284
|
throw new WorkflowExecutionError(
|
|
@@ -614,7 +612,7 @@ export async function completeWorkflow(
|
|
|
614
612
|
|
|
615
613
|
// Trigger compensation if workflow failed and has compensatable activities (Phase 8.2)
|
|
616
614
|
if (status === 'FAILED') {
|
|
617
|
-
const definition = await em
|
|
615
|
+
const definition = await findDefinitionForInstance(em, instance)
|
|
618
616
|
|
|
619
617
|
if (definition && checkIfCompensationNeeded(definition)) {
|
|
620
618
|
try {
|
|
@@ -836,9 +834,14 @@ export async function resumeWorkflowAfterActivities(
|
|
|
836
834
|
fromStepId: instance.currentStepId,
|
|
837
835
|
})
|
|
838
836
|
|
|
839
|
-
const definition = await trx
|
|
840
|
-
|
|
841
|
-
|
|
837
|
+
const definition = await findDefinitionForInstance(trx, instance)
|
|
838
|
+
if (!definition) {
|
|
839
|
+
throw new WorkflowExecutionError(
|
|
840
|
+
`Workflow definition not found: ${instance.definitionId}`,
|
|
841
|
+
'DEFINITION_NOT_FOUND',
|
|
842
|
+
{ definitionId: instance.definitionId }
|
|
843
|
+
)
|
|
844
|
+
}
|
|
842
845
|
|
|
843
846
|
const step = definition.definition.steps.find(s => s.stepId === pendingTransition.toStepId)
|
|
844
847
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations'
|
|
2
|
+
|
|
3
|
+
const LEGACY_WORKFLOW_ID = 'workflows.checkout-demo'
|
|
4
|
+
const LEGACY_WORKFLOW_NAME = 'Checkout with Payment Webhook'
|
|
5
|
+
const LEGACY_ACTIVITY_ID = 'reserve_inventory'
|
|
6
|
+
const LEGACY_ACTIVITY_URL = '{{env.INVENTORY_SERVICE_URL}}/api/inventory/reserve'
|
|
7
|
+
const TRANSITIONS_REPLACED_BY_CODE_DEFINITION = [
|
|
8
|
+
'cart_to_customer_info',
|
|
9
|
+
'customer_info_to_payment',
|
|
10
|
+
'confirmation_to_order',
|
|
11
|
+
] as const
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* SQL fragment (for the UPDATE `where` clause) that matches ONLY the exact
|
|
15
|
+
* legacy checkout seed: workflow id, name, version, and the presence of the
|
|
16
|
+
* obsolete `reserve_inventory` `CALL_WEBHOOK` activity with its exact env-based
|
|
17
|
+
* URL. The match is intentionally strict so the repair never touches a row that
|
|
18
|
+
* is not the known-broken seed. A tenant whose persisted row diverges from this
|
|
19
|
+
* fingerprint (renamed, re-versioned, or a different webhook URL) is left
|
|
20
|
+
* untouched — the trade-off is safety over coverage for an unknown variant.
|
|
21
|
+
*/
|
|
22
|
+
export function legacyCheckoutPredicate(): string {
|
|
23
|
+
return `
|
|
24
|
+
"wf"."workflow_id" = '${LEGACY_WORKFLOW_ID}'
|
|
25
|
+
and "wf"."workflow_name" = '${LEGACY_WORKFLOW_NAME}'
|
|
26
|
+
and "wf"."version" = 1
|
|
27
|
+
and exists (
|
|
28
|
+
select 1
|
|
29
|
+
from jsonb_array_elements(coalesce("wf"."definition"->'transitions', '[]'::jsonb)) as "transition"("value")
|
|
30
|
+
cross join lateral jsonb_array_elements(coalesce("transition"."value"->'activities', '[]'::jsonb)) as "activity"("value")
|
|
31
|
+
where "activity"."value"->>'activityId' = '${LEGACY_ACTIVITY_ID}'
|
|
32
|
+
and "activity"."value"->>'activityType' = 'CALL_WEBHOOK'
|
|
33
|
+
and "activity"."value"#>>'{config,url}' = '${LEGACY_ACTIVITY_URL}'
|
|
34
|
+
)
|
|
35
|
+
`.trim()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The exact UPDATE the migration runs. Exported so the DB-level regression test
|
|
40
|
+
* (`__integration__/TC-WF-031`) executes the identical SQL against Postgres,
|
|
41
|
+
* making the jsonb transformation itself — not just its rendered string —
|
|
42
|
+
* the thing under test.
|
|
43
|
+
*
|
|
44
|
+
* It rebuilds the `transitions` array in original order, dropping the
|
|
45
|
+
* `activities` key from exactly the three transitions the maintained code
|
|
46
|
+
* definition made side-effect-free. All other keys (e.g. `preConditions`) and
|
|
47
|
+
* all other transitions — including `confirmation_to_end`, whose remaining
|
|
48
|
+
* activities are internal (`CALL_API`/`SEND_EMAIL`/`EMIT_EVENT`) and never trip
|
|
49
|
+
* the outbound URL guard — are preserved verbatim.
|
|
50
|
+
*/
|
|
51
|
+
export function buildLegacyCheckoutRepairSql(): string {
|
|
52
|
+
const strippedTransitionList = TRANSITIONS_REPLACED_BY_CODE_DEFINITION
|
|
53
|
+
.map((id) => `'${id}'`)
|
|
54
|
+
.join(', ')
|
|
55
|
+
return `
|
|
56
|
+
update "workflow_definitions" as "wf"
|
|
57
|
+
set "definition" = jsonb_set(
|
|
58
|
+
"wf"."definition",
|
|
59
|
+
'{transitions}',
|
|
60
|
+
coalesce(
|
|
61
|
+
(
|
|
62
|
+
select jsonb_agg(
|
|
63
|
+
case
|
|
64
|
+
when "transition"."value"->>'transitionId' in (${strippedTransitionList})
|
|
65
|
+
then "transition"."value" - 'activities'
|
|
66
|
+
else "transition"."value"
|
|
67
|
+
end
|
|
68
|
+
order by "transition"."ordinality"
|
|
69
|
+
)
|
|
70
|
+
from jsonb_array_elements(
|
|
71
|
+
coalesce("wf"."definition"->'transitions', '[]'::jsonb)
|
|
72
|
+
) with ordinality as "transition"("value", "ordinality")
|
|
73
|
+
),
|
|
74
|
+
'[]'::jsonb
|
|
75
|
+
)
|
|
76
|
+
),
|
|
77
|
+
"updated_at" = current_timestamp
|
|
78
|
+
where "wf"."code_workflow_id" is null
|
|
79
|
+
and "wf"."deleted_at" is null
|
|
80
|
+
and ${legacyCheckoutPredicate()};
|
|
81
|
+
`.trim()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Repairs the former seeded checkout demo after it was renamed to the same ID
|
|
86
|
+
* as the code-defined workflow. The DB-first lookup shadows the current code
|
|
87
|
+
* definition, including its obsolete external inventory webhook.
|
|
88
|
+
*
|
|
89
|
+
* The persisted row must remain active because historical and in-flight
|
|
90
|
+
* workflow instances reference it by definition_id — a random UUID the
|
|
91
|
+
* code-registry fallback in find-definition.ts deliberately never substitutes.
|
|
92
|
+
* The migration removes the activity arrays from the three transitions that
|
|
93
|
+
* the maintained code definition intentionally made side-effect-free,
|
|
94
|
+
* preserving the row and all instance references while keeping the demo
|
|
95
|
+
* self-contained. (Fresh installs never had this row; they execute the virtual
|
|
96
|
+
* code definition directly via findDefinitionForInstance.)
|
|
97
|
+
*
|
|
98
|
+
* `confirmation_to_end` is deliberately NOT rebuilt: unlike the three stripped
|
|
99
|
+
* transitions it still holds activities in the maintained code definition, and
|
|
100
|
+
* its legacy-only extra (`emit_order_completed`, an internal `EMIT_EVENT`) is
|
|
101
|
+
* harmless — it emits no external request, so leaving it costs nothing and
|
|
102
|
+
* avoids fragile per-activity surgery that could re-introduce a failure.
|
|
103
|
+
*/
|
|
104
|
+
export class Migration20260715120000 extends Migration {
|
|
105
|
+
override async up(): Promise<void> {
|
|
106
|
+
this.addSql(buildLegacyCheckoutRepairSql())
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
override async down(): Promise<void> {
|
|
110
|
+
// Forward-only data repair. The removed demo-only activity payloads cannot
|
|
111
|
+
// be reconstructed safely, and restoring obsolete external webhooks would
|
|
112
|
+
// re-open issue #4179.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations'
|
|
2
|
+
|
|
3
|
+
const CODE_WORKFLOW_ID = 'workflows.checkout-demo'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Retires the persisted checkout-demo seed rows so runtime resolves the
|
|
7
|
+
* maintained CODE definition instead (issue #4211).
|
|
8
|
+
*
|
|
9
|
+
* Background: `workflows.checkout-demo` is now code-defined
|
|
10
|
+
* (`packages/core/src/modules/workflows/workflows.ts`), and only the code
|
|
11
|
+
* definition forwards the cart's line items + shipping/tax adjustments to
|
|
12
|
+
* `POST /api/sales/orders`. But existing tenants still carry a persisted
|
|
13
|
+
* `workflow_definitions` row from the old seed — originally seeded under ids
|
|
14
|
+
* like `checkout_simple_v1` / names like `Simple Checkout Flow` and later
|
|
15
|
+
* `Checkout with Payment Webhook`, then renamed to `workflows.checkout-demo`
|
|
16
|
+
* by Migration20260428102318. Its `create_order` body never sent `lines`, and
|
|
17
|
+
* because `findWorkflowDefinition` resolves the DB row first, that stale row
|
|
18
|
+
* SHADOWS the code definition — so orders come out with zero line items and
|
|
19
|
+
* $0.00 totals. Patching the persisted body is unreliable: the seed's name and
|
|
20
|
+
* body shape vary across historical versions, so a fingerprint on either misses
|
|
21
|
+
* whole cohorts of tenants (the defect QA hit).
|
|
22
|
+
*
|
|
23
|
+
* Instead, soft-delete the seeded rows. This is body/name-agnostic, so it fixes
|
|
24
|
+
* every historical seed variant:
|
|
25
|
+
* - New instances: `findWorkflowDefinition` filters `deleted_at IS NULL`, so
|
|
26
|
+
* the retired row is skipped and the code definition is used (correct body).
|
|
27
|
+
* - In-flight instances: `findDefinitionForInstance` loads the definition by
|
|
28
|
+
* `id` with no soft-delete filter (WorkflowDefinition has no global filter),
|
|
29
|
+
* so instances that reference the row keep resolving and advancing.
|
|
30
|
+
*
|
|
31
|
+
* Scope is limited to `code_workflow_id IS NULL` rows — user customizations of
|
|
32
|
+
* the code definition set `code_workflow_id` and MUST NOT be retired. The
|
|
33
|
+
* `deleted_at IS NULL` guard makes the migration idempotent.
|
|
34
|
+
*/
|
|
35
|
+
export function buildRetireSeededCheckoutDemoSql(): string {
|
|
36
|
+
return `
|
|
37
|
+
update "workflow_definitions" as "wf"
|
|
38
|
+
set "deleted_at" = current_timestamp,
|
|
39
|
+
"updated_at" = current_timestamp
|
|
40
|
+
where "wf"."workflow_id" = '${CODE_WORKFLOW_ID}'
|
|
41
|
+
and "wf"."code_workflow_id" is null
|
|
42
|
+
and "wf"."deleted_at" is null;
|
|
43
|
+
`.trim()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class Migration20260716120000 extends Migration {
|
|
47
|
+
override async up(): Promise<void> {
|
|
48
|
+
this.addSql(buildRetireSeededCheckoutDemoSql())
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
override async down(): Promise<void> {
|
|
52
|
+
// Forward-only. Restoring `deleted_at = null` would re-shadow the maintained
|
|
53
|
+
// code definition with the stale seed body and re-introduce zero-line orders
|
|
54
|
+
// (#4211). The code definition supersedes the seed, so there is nothing to
|
|
55
|
+
// restore.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -239,6 +239,8 @@ const checkoutDemo = defineWorkflow({
|
|
|
239
239
|
customerEntityId: '{{context.customer.id}}',
|
|
240
240
|
currencyCode: '{{context.cart.currency}}',
|
|
241
241
|
placedAt: '{{now}}',
|
|
242
|
+
lines: '{{context.cart.orderLines}}',
|
|
243
|
+
adjustments: '{{context.cart.orderAdjustments}}',
|
|
242
244
|
grandTotalGrossAmount: '{{context.cart.total}}',
|
|
243
245
|
},
|
|
244
246
|
validateTenantMatch: true,
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
2
|
-
import { Attachment } from "../data/entities.js";
|
|
3
|
-
const logger = createLogger("attachments").child({ component: "reconcileOrganization" });
|
|
4
|
-
const LIBRARY_ENTITY_ID = "attachments:library";
|
|
5
|
-
const DEFAULT_BATCH_SIZE = 500;
|
|
6
|
-
function readParentOrganizationId(record) {
|
|
7
|
-
const raw = record["organization_id"] ?? record["organizationId"];
|
|
8
|
-
if (typeof raw === "string" && raw.trim().length) return raw.trim();
|
|
9
|
-
return null;
|
|
10
|
-
}
|
|
11
|
-
function ensureBucket(report, entityId) {
|
|
12
|
-
const existing = report.byEntity[entityId];
|
|
13
|
-
if (existing) return existing;
|
|
14
|
-
const bucket = { scanned: 0, updated: 0, unresolved: 0 };
|
|
15
|
-
report.byEntity[entityId] = bucket;
|
|
16
|
-
return bucket;
|
|
17
|
-
}
|
|
18
|
-
async function reconcileAttachmentOrganizations(opts) {
|
|
19
|
-
const { em, queryEngine, tenantId } = opts;
|
|
20
|
-
const batchSize = opts.batchSize && opts.batchSize > 0 ? opts.batchSize : DEFAULT_BATCH_SIZE;
|
|
21
|
-
const report = {
|
|
22
|
-
scanned: 0,
|
|
23
|
-
updated: 0,
|
|
24
|
-
unresolved: 0,
|
|
25
|
-
skippedVirtual: 0,
|
|
26
|
-
byEntity: {}
|
|
27
|
-
};
|
|
28
|
-
const rows = await em.getConnection().execute(
|
|
29
|
-
"select id, entity_id, record_id, organization_id from attachments where tenant_id = ?",
|
|
30
|
-
[tenantId]
|
|
31
|
-
);
|
|
32
|
-
report.scanned = rows.length;
|
|
33
|
-
if (!rows.length) return report;
|
|
34
|
-
const groups = /* @__PURE__ */ new Map();
|
|
35
|
-
for (const row of rows) {
|
|
36
|
-
if (!row.id || !row.entity_id || !row.record_id) continue;
|
|
37
|
-
const list = groups.get(row.entity_id) ?? [];
|
|
38
|
-
list.push(row);
|
|
39
|
-
groups.set(row.entity_id, list);
|
|
40
|
-
}
|
|
41
|
-
const pendingUpdates = [];
|
|
42
|
-
for (const [entityId, group] of groups) {
|
|
43
|
-
const bucket = ensureBucket(report, entityId);
|
|
44
|
-
bucket.scanned += group.length;
|
|
45
|
-
if (entityId === LIBRARY_ENTITY_ID) {
|
|
46
|
-
report.skippedVirtual += group.length;
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
const recordIds = Array.from(
|
|
50
|
-
new Set(group.map((row) => row.record_id).filter((value) => !!value))
|
|
51
|
-
);
|
|
52
|
-
const parentOrgByRecordId = /* @__PURE__ */ new Map();
|
|
53
|
-
let resolvable = true;
|
|
54
|
-
for (let index = 0; index < recordIds.length; index += batchSize) {
|
|
55
|
-
const chunk = recordIds.slice(index, index + batchSize);
|
|
56
|
-
try {
|
|
57
|
-
const result = await queryEngine.query(entityId, {
|
|
58
|
-
fields: ["id", "organization_id"],
|
|
59
|
-
filters: { id: chunk.length === 1 ? { $eq: chunk[0] } : { $in: chunk } },
|
|
60
|
-
tenantId,
|
|
61
|
-
withDeleted: true,
|
|
62
|
-
page: { pageSize: Math.max(chunk.length, 1) }
|
|
63
|
-
});
|
|
64
|
-
for (const item of result.items ?? []) {
|
|
65
|
-
const record = item;
|
|
66
|
-
const recordId = record.id != null ? String(record.id) : null;
|
|
67
|
-
if (!recordId) continue;
|
|
68
|
-
parentOrgByRecordId.set(recordId, readParentOrganizationId(record));
|
|
69
|
-
}
|
|
70
|
-
} catch (error) {
|
|
71
|
-
resolvable = false;
|
|
72
|
-
logger.warn("org reconcile: cannot resolve parent organization for entity", {
|
|
73
|
-
entityId,
|
|
74
|
-
error
|
|
75
|
-
});
|
|
76
|
-
break;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
if (!resolvable) {
|
|
80
|
-
bucket.unresolved += group.length;
|
|
81
|
-
report.unresolved += group.length;
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
for (const row of group) {
|
|
85
|
-
const target = row.record_id ? parentOrgByRecordId.get(row.record_id) : null;
|
|
86
|
-
if (!target) {
|
|
87
|
-
bucket.unresolved += 1;
|
|
88
|
-
report.unresolved += 1;
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
if ((row.organization_id ?? null) === target) continue;
|
|
92
|
-
pendingUpdates.push({ id: row.id, organizationId: target });
|
|
93
|
-
bucket.updated += 1;
|
|
94
|
-
report.updated += 1;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
for (let index = 0; index < pendingUpdates.length; index += batchSize) {
|
|
98
|
-
const chunk = pendingUpdates.slice(index, index + batchSize);
|
|
99
|
-
for (const update of chunk) {
|
|
100
|
-
const reference = em.getReference(Attachment, update.id);
|
|
101
|
-
reference.organizationId = update.organizationId;
|
|
102
|
-
}
|
|
103
|
-
await em.flush();
|
|
104
|
-
}
|
|
105
|
-
return report;
|
|
106
|
-
}
|
|
107
|
-
export {
|
|
108
|
-
reconcileAttachmentOrganizations
|
|
109
|
-
};
|
|
110
|
-
//# sourceMappingURL=reconcileOrganization.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../../src/modules/attachments/lib/reconcileOrganization.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport { Attachment } from '../data/entities'\n\nconst logger = createLogger('attachments').child({ component: 'reconcileOrganization' })\n\n/**\n * Virtual entity id for library attachments \u2014 these are not attached to a\n * parent record that owns an organization, so there is nothing to reconcile\n * against. They are counted as skipped rather than unresolved.\n */\nconst LIBRARY_ENTITY_ID = 'attachments:library'\n\nconst DEFAULT_BATCH_SIZE = 500\n\nexport type AttachmentOrgReconcileEntityStat = {\n scanned: number\n updated: number\n unresolved: number\n}\n\nexport type AttachmentOrgReconcileReport = {\n scanned: number\n updated: number\n unresolved: number\n skippedVirtual: number\n byEntity: Record<string, AttachmentOrgReconcileEntityStat>\n}\n\ntype AttachmentScanRow = {\n id: string\n entity_id: string | null\n record_id: string | null\n organization_id: string | null\n}\n\nfunction readParentOrganizationId(record: Record<string, unknown>): string | null {\n const raw = record['organization_id'] ?? record['organizationId']\n if (typeof raw === 'string' && raw.trim().length) return raw.trim()\n return null\n}\n\nfunction ensureBucket(\n report: AttachmentOrgReconcileReport,\n entityId: string,\n): AttachmentOrgReconcileEntityStat {\n const existing = report.byEntity[entityId]\n if (existing) return existing\n const bucket: AttachmentOrgReconcileEntityStat = { scanned: 0, updated: 0, unresolved: 0 }\n report.byEntity[entityId] = bucket\n return bucket\n}\n\n/**\n * Reconcile the `organization_id` of existing attachments to the organization\n * of the record they are attached to.\n *\n * Background (#3765): before the upload route became selected-organization\n * aware, a multi-org admin who switched the header organization uploaded files\n * that were silently stored under their *home* organization instead of the\n * selected one (the organization of the parent record). Those rows are now\n * invisible to org-scoped reads and stay orphaned. This heals them forward.\n *\n * The parent record's organization is the ground truth: an attachment is not\n * distinguishable from a legitimately home-org one by looking at the attachment\n * alone. Parent organizations are resolved generically through the Query Engine\n * by `entityId` (works for both base and custom entities, tenant-scoped and\n * organization-agnostic so the parent's real org is returned regardless of\n * which org the caller sits in) \u2014 the same mechanism the attachments module\n * already uses to enrich assignment details.\n *\n * The reconciliation is:\n * - idempotent \u2014 rows already matching their parent are left untouched;\n * - conservative \u2014 when the parent org cannot be resolved (unregistered\n * entity id, hard-deleted parent, parent without an org column) the row is\n * counted as `unresolved` and left as-is rather than guessed at;\n * - tenant-scoped \u2014 it reconciles every attachment in the tenant across all\n * organizations (the whole point is moving rows between orgs).\n *\n * Writes go through the passed `EntityManager` (which the caller runs inside a\n * transaction) so the whole reconciliation commits atomically.\n */\nexport async function reconcileAttachmentOrganizations(opts: {\n em: EntityManager\n queryEngine: QueryEngine\n tenantId: string\n batchSize?: number\n}): Promise<AttachmentOrgReconcileReport> {\n const { em, queryEngine, tenantId } = opts\n const batchSize = opts.batchSize && opts.batchSize > 0 ? opts.batchSize : DEFAULT_BATCH_SIZE\n const report: AttachmentOrgReconcileReport = {\n scanned: 0,\n updated: 0,\n unresolved: 0,\n skippedVirtual: 0,\n byEntity: {},\n }\n\n const rows = (await em.getConnection().execute(\n 'select id, entity_id, record_id, organization_id from attachments where tenant_id = ?',\n [tenantId],\n )) as AttachmentScanRow[]\n report.scanned = rows.length\n if (!rows.length) return report\n\n const groups = new Map<string, AttachmentScanRow[]>()\n for (const row of rows) {\n if (!row.id || !row.entity_id || !row.record_id) continue\n const list = groups.get(row.entity_id) ?? []\n list.push(row)\n groups.set(row.entity_id, list)\n }\n\n const pendingUpdates: Array<{ id: string; organizationId: string }> = []\n\n for (const [entityId, group] of groups) {\n const bucket = ensureBucket(report, entityId)\n bucket.scanned += group.length\n\n if (entityId === LIBRARY_ENTITY_ID) {\n report.skippedVirtual += group.length\n continue\n }\n\n const recordIds = Array.from(\n new Set(group.map((row) => row.record_id).filter((value): value is string => !!value)),\n )\n const parentOrgByRecordId = new Map<string, string | null>()\n let resolvable = true\n\n for (let index = 0; index < recordIds.length; index += batchSize) {\n const chunk = recordIds.slice(index, index + batchSize)\n try {\n const result = await queryEngine.query(entityId as any, {\n fields: ['id', 'organization_id'],\n filters: { id: chunk.length === 1 ? { $eq: chunk[0] } : { $in: chunk } },\n tenantId,\n withDeleted: true,\n page: { pageSize: Math.max(chunk.length, 1) },\n })\n for (const item of result.items ?? []) {\n const record = item as Record<string, unknown>\n const recordId = record.id != null ? String(record.id) : null\n if (!recordId) continue\n parentOrgByRecordId.set(recordId, readParentOrganizationId(record))\n }\n } catch (error) {\n resolvable = false\n logger.warn('org reconcile: cannot resolve parent organization for entity', {\n entityId,\n error,\n })\n break\n }\n }\n\n if (!resolvable) {\n bucket.unresolved += group.length\n report.unresolved += group.length\n continue\n }\n\n for (const row of group) {\n const target = row.record_id ? parentOrgByRecordId.get(row.record_id) : null\n if (!target) {\n bucket.unresolved += 1\n report.unresolved += 1\n continue\n }\n if ((row.organization_id ?? null) === target) continue\n pendingUpdates.push({ id: row.id, organizationId: target })\n bucket.updated += 1\n report.updated += 1\n }\n }\n\n for (let index = 0; index < pendingUpdates.length; index += batchSize) {\n const chunk = pendingUpdates.slice(index, index + batchSize)\n for (const update of chunk) {\n const reference = em.getReference(Attachment, update.id)\n reference.organizationId = update.organizationId\n }\n await em.flush()\n }\n\n return report\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|