@open-mercato/core 0.6.5-develop.4670.1.afe50dfd5c → 0.6.5-develop.4691.1.bb409545b3
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/AGENTS.md +31 -0
- package/dist/helpers/integration/standaloneEnv.js +58 -0
- package/dist/helpers/integration/standaloneEnv.js.map +7 -0
- package/dist/helpers/integration/undoHarness.js +97 -2
- package/dist/helpers/integration/undoHarness.js.map +2 -2
- package/dist/modules/customers/commands/deals.js +80 -83
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/entities/lib/helpers.js +79 -82
- package/dist/modules/entities/lib/helpers.js.map +2 -2
- package/dist/modules/query_index/lib/indexer.js +50 -24
- package/dist/modules/query_index/lib/indexer.js.map +2 -2
- package/dist/modules/query_index/subscribers/delete_one.js +28 -15
- package/dist/modules/query_index/subscribers/delete_one.js.map +2 -2
- package/dist/modules/query_index/subscribers/upsert_one.js +31 -13
- package/dist/modules/query_index/subscribers/upsert_one.js.map +2 -2
- package/dist/modules/resources/backend/resources/resources/[id]/page.js +3 -0
- package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
- package/dist/modules/workflows/lib/workflow-executor.js +15 -0
- package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/standaloneEnv.ts +62 -0
- package/src/helpers/integration/undoHarness.ts +132 -1
- package/src/modules/customers/AGENTS.md +1 -0
- package/src/modules/customers/commands/deals.ts +106 -111
- package/src/modules/entities/lib/helpers.ts +43 -21
- package/src/modules/query_index/lib/indexer.ts +71 -24
- package/src/modules/query_index/subscribers/delete_one.ts +36 -16
- package/src/modules/query_index/subscribers/upsert_one.ts +44 -15
- package/src/modules/resources/backend/resources/resources/[id]/page.tsx +11 -0
- package/src/modules/workflows/lib/workflow-executor.ts +17 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { type APIRequestContext, type APIResponse, expect } from '@playwright/test'
|
|
1
|
+
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test'
|
|
2
|
+
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
2
3
|
import { apiRequest } from './api'
|
|
4
|
+
import { expectId, readJsonSafe } from './generalFixtures'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Shared harness for verifying Undo/Redo correctness against the real command bus.
|
|
@@ -14,6 +16,7 @@ const HEADER_PREFIX = 'omop:'
|
|
|
14
16
|
const UNDO_PATH = '/api/audit_logs/audit-logs/actions/undo'
|
|
15
17
|
const REDO_PATH = '/api/audit_logs/audit-logs/actions/redo'
|
|
16
18
|
const ACTIONS_PATH = '/api/audit_logs/audit-logs/actions'
|
|
19
|
+
export const UNDO_TESTS_DISABLED_ENV = 'OM_INTEGRATION_UNDO_TESTS_DISABLED'
|
|
17
20
|
|
|
18
21
|
export type Operation = {
|
|
19
22
|
logId: string
|
|
@@ -23,6 +26,26 @@ export type Operation = {
|
|
|
23
26
|
resourceId: string | null
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
export type CrudUndoEntityConfig = {
|
|
30
|
+
label: string
|
|
31
|
+
collectionPath: string
|
|
32
|
+
field: string
|
|
33
|
+
createPayload: (stamp: string) => Record<string, unknown>
|
|
34
|
+
updatePayload: (id: string, stamp: string) => Record<string, unknown>
|
|
35
|
+
readPath?: (id: string) => string
|
|
36
|
+
deletePath?: (id: string) => string
|
|
37
|
+
createStatus?: number
|
|
38
|
+
updateStatus?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function undoTestsDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
42
|
+
return parseBooleanWithDefault(env[UNDO_TESTS_DISABLED_ENV], false)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function skipIfUndoTestsDisabled(): void {
|
|
46
|
+
test.skip(undoTestsDisabled(), `${UNDO_TESTS_DISABLED_ENV} is set — undo/redo integration tests skipped`)
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
/** Parse the `x-om-operation` header into a structured operation, or null when absent/malformed. */
|
|
27
50
|
export function extractOperation(response: APIResponse): Operation | null {
|
|
28
51
|
const header = response.headers()['x-om-operation']
|
|
@@ -109,3 +132,111 @@ export function assertFieldsEqual(
|
|
|
109
132
|
).toBe(JSON.stringify((expected as Record<string, unknown>)[field]))
|
|
110
133
|
}
|
|
111
134
|
}
|
|
135
|
+
|
|
136
|
+
function findRecord(body: unknown, id: string): Record<string, unknown> | null {
|
|
137
|
+
if (!body || typeof body !== 'object') return null
|
|
138
|
+
if (!Array.isArray(body) && (body as Record<string, unknown>).id === id) {
|
|
139
|
+
return body as Record<string, unknown>
|
|
140
|
+
}
|
|
141
|
+
for (const value of Array.isArray(body) ? body : Object.values(body)) {
|
|
142
|
+
const found = findRecord(value, id)
|
|
143
|
+
if (found) return found
|
|
144
|
+
}
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function readRecord(
|
|
149
|
+
request: APIRequestContext,
|
|
150
|
+
token: string,
|
|
151
|
+
entity: CrudUndoEntityConfig,
|
|
152
|
+
id: string,
|
|
153
|
+
): Promise<Record<string, unknown> | null> {
|
|
154
|
+
const path = entity.readPath?.(id) ?? `${entity.collectionPath}?id=${encodeURIComponent(id)}`
|
|
155
|
+
const response = await apiRequest(request, 'GET', path, { token })
|
|
156
|
+
const body = await readJsonSafe(response)
|
|
157
|
+
if (!response.ok()) return null
|
|
158
|
+
return findRecord(body, id)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function fieldValue(record: Record<string, unknown> | null, field: string): unknown {
|
|
162
|
+
return record?.[field]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function deleteEntity(
|
|
166
|
+
request: APIRequestContext,
|
|
167
|
+
token: string,
|
|
168
|
+
entity: CrudUndoEntityConfig,
|
|
169
|
+
id: string,
|
|
170
|
+
): Promise<APIResponse> {
|
|
171
|
+
const path = entity.deletePath?.(id) ?? `${entity.collectionPath}?id=${encodeURIComponent(id)}`
|
|
172
|
+
return apiRequest(request, 'DELETE', path, { token })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function runCrudUndoRoundTrip(
|
|
176
|
+
request: APIRequestContext,
|
|
177
|
+
token: string,
|
|
178
|
+
entity: CrudUndoEntityConfig,
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
const stamp = `${Date.now()}${Math.floor(Math.random() * 1000)}`
|
|
181
|
+
let createUndoId: string | null = null
|
|
182
|
+
let cycleId: string | null = null
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const createUndoRes = await apiRequest(request, 'POST', entity.collectionPath, {
|
|
186
|
+
token,
|
|
187
|
+
data: entity.createPayload(`${stamp}a`),
|
|
188
|
+
})
|
|
189
|
+
expect(createUndoRes.status(), `${entity.label} create-for-undo status`).toBe(entity.createStatus ?? 201)
|
|
190
|
+
const createUndoOp = expectOperation(createUndoRes, `${entity.label}.create`)
|
|
191
|
+
createUndoId = createUndoOp.resourceId || expectId((await readJsonSafe<Record<string, unknown>>(createUndoRes))?.id, `${entity.label} create id`)
|
|
192
|
+
expect(fieldValue(await readRecord(request, token, entity, createUndoId), entity.field), `${entity.label} field readable after create`).toBeDefined()
|
|
193
|
+
|
|
194
|
+
await undoOk(request, token, createUndoOp.undoToken, `${entity.label} undo create`)
|
|
195
|
+
expect(await readRecord(request, token, entity, createUndoId), `${entity.label} create→undo soft-deletes/removes the record (I3)`).toBeNull()
|
|
196
|
+
await expectTokenConsumed(request, token, createUndoOp.undoToken, `${entity.label} create token consumed (I5)`)
|
|
197
|
+
|
|
198
|
+
const createRes = await apiRequest(request, 'POST', entity.collectionPath, {
|
|
199
|
+
token,
|
|
200
|
+
data: entity.createPayload(`${stamp}b`),
|
|
201
|
+
})
|
|
202
|
+
expect(createRes.status(), `${entity.label} create status`).toBe(entity.createStatus ?? 201)
|
|
203
|
+
const createOp = expectOperation(createRes, `${entity.label}.create`)
|
|
204
|
+
cycleId = createOp.resourceId || expectId((await readJsonSafe<Record<string, unknown>>(createRes))?.id, `${entity.label} cycle id`)
|
|
205
|
+
|
|
206
|
+
const beforeUpdate = await readRecord(request, token, entity, cycleId)
|
|
207
|
+
const beforeValue = fieldValue(beforeUpdate, entity.field)
|
|
208
|
+
expect(beforeValue, `${entity.label} field readable before update`).toBeDefined()
|
|
209
|
+
|
|
210
|
+
const updateRes = await apiRequest(request, 'PUT', entity.collectionPath, {
|
|
211
|
+
token,
|
|
212
|
+
data: entity.updatePayload(cycleId, stamp),
|
|
213
|
+
})
|
|
214
|
+
expect(updateRes.status(), `${entity.label} update status`).toBe(entity.updateStatus ?? 200)
|
|
215
|
+
const updateOp = expectOperation(updateRes, `${entity.label}.update`)
|
|
216
|
+
const afterUpdate = await readRecord(request, token, entity, cycleId)
|
|
217
|
+
const afterUpdateValue = fieldValue(afterUpdate, entity.field)
|
|
218
|
+
expect(JSON.stringify(afterUpdateValue), `${entity.label} field changed by update`).not.toBe(JSON.stringify(beforeValue))
|
|
219
|
+
|
|
220
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
221
|
+
await undoOk(request, token, updateOp.undoToken, `${entity.label} undo update`)
|
|
222
|
+
const afterUndo = await readRecord(request, token, entity, cycleId)
|
|
223
|
+
expect(JSON.stringify(fieldValue(afterUndo, entity.field)), `${entity.label} update→undo restores ${entity.field} (I1)`).toBe(JSON.stringify(beforeValue))
|
|
224
|
+
if (typeof beforeUpdate?.updatedAt === 'string' && typeof afterUndo?.updatedAt === 'string') {
|
|
225
|
+
expect(afterUndo.updatedAt, `${entity.label} undo bumps updatedAt`).not.toBe(beforeUpdate.updatedAt)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
await redoOk(request, token, updateOp.logId, `${entity.label} redo update`)
|
|
229
|
+
expect(JSON.stringify(fieldValue(await readRecord(request, token, entity, cycleId), entity.field)), `${entity.label} redo re-applies update (I6)`).toBe(JSON.stringify(afterUpdateValue))
|
|
230
|
+
|
|
231
|
+
const deleteRes = await deleteEntity(request, token, entity, cycleId)
|
|
232
|
+
expect(deleteRes.ok(), `${entity.label} delete status ${deleteRes.status()}`).toBeTruthy()
|
|
233
|
+
const deleteOp = expectOperation(deleteRes, `${entity.label}.delete`)
|
|
234
|
+
expect(await readRecord(request, token, entity, cycleId), `${entity.label} deleted record should not read`).toBeNull()
|
|
235
|
+
|
|
236
|
+
await undoOk(request, token, deleteOp.undoToken, `${entity.label} undo delete`)
|
|
237
|
+
expect(fieldValue(await readRecord(request, token, entity, cycleId), entity.field), `${entity.label} delete→undo re-materializes (I2)`).toBeDefined()
|
|
238
|
+
} finally {
|
|
239
|
+
if (createUndoId) await deleteEntity(request, token, entity, createUndoId).catch(() => {})
|
|
240
|
+
if (cycleId) await deleteEntity(request, token, entity, cycleId).catch(() => {})
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -87,6 +87,7 @@ Commands (`commands/people.ts`) demonstrate:
|
|
|
87
87
|
3. Restore via `buildCustomFieldResetMap(before.custom, after.custom)` in undo
|
|
88
88
|
4. Side effects with `emitCrudSideEffects` and `emitCrudUndoSideEffects`
|
|
89
89
|
5. Include `indexer: { entityType, cacheAliases }` in both directions
|
|
90
|
+
6. **Prefer `runCrudCommandWrite` for new commands** that combine entity writes + custom fields + side effects in one logical step. Reference: the migrated `updateDealCommand.execute` in `commands/deals.ts`. See `packages/core/AGENTS.md` → Entity Update Safety for the contract and `packages/shared/AGENTS.md` → `commands/runCrudCommandWrite` for the import.
|
|
90
91
|
|
|
91
92
|
## Transaction Safety
|
|
92
93
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
normalizeAuthorUserId,
|
|
10
10
|
} from '@open-mercato/shared/lib/commands/helpers'
|
|
11
11
|
import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
|
|
12
|
+
import { runCrudCommandWrite } from '@open-mercato/shared/lib/commands/runCrudCommandWrite'
|
|
12
13
|
import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
13
14
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
14
15
|
import {
|
|
@@ -569,120 +570,114 @@ const updateDealCommand: CommandHandler<DealUpdateInput, { dealId: string }> = {
|
|
|
569
570
|
let nextPipelineStageLabel: string | null = null
|
|
570
571
|
let resolvedCurrentPipelineStageLabel: string | null = null
|
|
571
572
|
|
|
572
|
-
await
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
parsed.pipelineId !== undefined || parsed.pipelineStageId !== undefined
|
|
576
|
-
const requestedPipelineStageId =
|
|
577
|
-
parsed.pipelineStageId !== undefined
|
|
578
|
-
? parsed.pipelineStageId ?? null
|
|
579
|
-
: record.pipelineStageId ?? null
|
|
580
|
-
const requestedPipelineId =
|
|
581
|
-
parsed.pipelineId !== undefined ? parsed.pipelineId ?? null : record.pipelineId ?? null
|
|
582
|
-
|
|
583
|
-
nextStageSnapshot = requestedPipelineStageId && (pipelineAssignmentChanged || !record.pipelineStage)
|
|
584
|
-
? await loadPipelineStageSnapshot(em, requestedPipelineStageId, record.tenantId, record.organizationId)
|
|
585
|
-
: null
|
|
586
|
-
if (pipelineAssignmentChanged) {
|
|
587
|
-
nextPipelineAssignment = resolvePipelineAssignment({
|
|
588
|
-
pipelineId: requestedPipelineId,
|
|
589
|
-
pipelineStageId: requestedPipelineStageId,
|
|
590
|
-
stageSnapshot: nextStageSnapshot,
|
|
591
|
-
})
|
|
592
|
-
}
|
|
593
|
-
nextPipelineStageLabel = nextStageSnapshot
|
|
594
|
-
? (await ensureDictionaryEntry(em, {
|
|
595
|
-
tenantId: record.tenantId,
|
|
596
|
-
organizationId: record.organizationId,
|
|
597
|
-
kind: 'pipeline_stage',
|
|
598
|
-
value: nextStageSnapshot.label,
|
|
599
|
-
}))?.value ?? nextStageSnapshot.label
|
|
600
|
-
: null
|
|
601
|
-
resolvedCurrentPipelineStageLabel =
|
|
602
|
-
!nextStageSnapshot && record.pipelineStageId && (parsed.pipelineStageId !== undefined || !record.pipelineStage)
|
|
603
|
-
? await resolvePipelineStageValue(em, record.pipelineStageId, record.tenantId, record.organizationId)
|
|
604
|
-
: null
|
|
605
|
-
},
|
|
606
|
-
() => {
|
|
607
|
-
if (parsed.title !== undefined) record.title = parsed.title
|
|
608
|
-
if (parsed.description !== undefined) record.description = parsed.description ?? null
|
|
609
|
-
if (parsed.status !== undefined) record.status = parsed.status ?? record.status
|
|
610
|
-
if (parsed.pipelineStage !== undefined) record.pipelineStage = parsed.pipelineStage ?? null
|
|
611
|
-
if (parsed.pipelineId !== undefined || (parsed.pipelineStageId !== undefined && nextStageSnapshot)) {
|
|
612
|
-
record.pipelineId = nextPipelineAssignment.pipelineId
|
|
613
|
-
}
|
|
614
|
-
if (parsed.pipelineStageId !== undefined) record.pipelineStageId = nextPipelineAssignment.pipelineStageId
|
|
615
|
-
|
|
616
|
-
if (nextPipelineStageLabel && (parsed.pipelineStageId !== undefined || !record.pipelineStage)) {
|
|
617
|
-
record.pipelineStage = nextPipelineStageLabel
|
|
618
|
-
} else if (resolvedCurrentPipelineStageLabel && (parsed.pipelineStageId !== undefined || !record.pipelineStage)) {
|
|
619
|
-
record.pipelineStage = resolvedCurrentPipelineStageLabel
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
if (parsed.valueAmount !== undefined) record.valueAmount = toNumericString(parsed.valueAmount)
|
|
623
|
-
if (parsed.valueCurrency !== undefined) record.valueCurrency = parsed.valueCurrency ?? null
|
|
624
|
-
if (parsed.probability !== undefined) record.probability = parsed.probability ?? null
|
|
625
|
-
if (parsed.expectedCloseAt !== undefined) record.expectedCloseAt = parsed.expectedCloseAt ?? null
|
|
626
|
-
if (parsed.ownerUserId !== undefined) record.ownerUserId = parsed.ownerUserId ?? null
|
|
627
|
-
if (parsed.source !== undefined) record.source = parsed.source ?? null
|
|
628
|
-
if (parsed.closureOutcome !== undefined) record.closureOutcome = parsed.closureOutcome ?? null
|
|
629
|
-
if (parsed.lossReasonId !== undefined) record.lossReasonId = parsed.lossReasonId ?? null
|
|
630
|
-
if (parsed.lossNotes !== undefined) record.lossNotes = parsed.lossNotes ?? null
|
|
631
|
-
},
|
|
632
|
-
async () => {
|
|
633
|
-
// CRITICAL: persist the scalar mutations above before any further `em.findOne` / sync
|
|
634
|
-
// helpers run inside this transaction. MikroORM v7's identity-map silently discards
|
|
635
|
-
// pending scalar changes on `record` if a query (such as the stage-transition lookup
|
|
636
|
-
// inside `upsertDealStageTransition`, or the linked-entity finds inside
|
|
637
|
-
// `syncDealPeople` / `syncDealCompanies`) executes on the same `EntityManager`
|
|
638
|
-
// before we explicitly flush. Without this flush, the entire kanban drag-and-drop
|
|
639
|
-
// returns 200 OK but never actually updates `customer_deals` rows — the card
|
|
640
|
-
// snaps back to its source lane on the next refetch (see SPEC-018).
|
|
641
|
-
await em.flush()
|
|
642
|
-
},
|
|
643
|
-
async () => {
|
|
644
|
-
const snapshot = nextStageSnapshot
|
|
645
|
-
if (!snapshot) return
|
|
646
|
-
const shouldRecord =
|
|
647
|
-
parsed.pipelineStageId !== undefined &&
|
|
648
|
-
parsed.pipelineStageId !== null &&
|
|
649
|
-
parsed.pipelineStageId !== previousPipelineStageId
|
|
650
|
-
if (!shouldRecord) return
|
|
651
|
-
await upsertDealStageTransition(em, {
|
|
652
|
-
deal: record,
|
|
653
|
-
pipelineId: snapshot.pipelineId,
|
|
654
|
-
stageId: snapshot.id,
|
|
655
|
-
stageLabel: nextPipelineStageLabel ?? snapshot.label,
|
|
656
|
-
stageOrder: snapshot.order,
|
|
657
|
-
transitionedByUserId: normalizedTransitionAuthorUserId,
|
|
658
|
-
})
|
|
659
|
-
},
|
|
660
|
-
() => syncDealPeople(em, record, parsed.personIds),
|
|
661
|
-
() => syncDealCompanies(em, record, parsed.companyIds),
|
|
662
|
-
], { transaction: true })
|
|
663
|
-
|
|
664
|
-
const de = (ctx.container.resolve('dataEngine') as DataEngine)
|
|
665
|
-
await setCustomFieldsIfAny({
|
|
666
|
-
dataEngine: de,
|
|
573
|
+
await runCrudCommandWrite({
|
|
574
|
+
ctx,
|
|
575
|
+
em,
|
|
667
576
|
entityId: DEAL_ENTITY_ID,
|
|
668
|
-
recordId: record.id,
|
|
669
|
-
organizationId: record.organizationId,
|
|
670
|
-
tenantId: record.tenantId,
|
|
671
|
-
values: custom,
|
|
672
|
-
notify: false,
|
|
673
|
-
})
|
|
674
|
-
|
|
675
|
-
await emitCrudSideEffects({
|
|
676
|
-
dataEngine: de,
|
|
677
577
|
action: 'updated',
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
id: record.id,
|
|
681
|
-
organizationId: record.organizationId,
|
|
682
|
-
tenantId: record.tenantId,
|
|
683
|
-
},
|
|
684
|
-
indexer: dealCrudIndexer,
|
|
578
|
+
scope: { tenantId: record.tenantId, organizationId: record.organizationId },
|
|
579
|
+
customFields: custom,
|
|
685
580
|
events: dealCrudEvents,
|
|
581
|
+
indexer: dealCrudIndexer,
|
|
582
|
+
sideEffect: () => ({
|
|
583
|
+
entity: record,
|
|
584
|
+
identifiers: {
|
|
585
|
+
id: record.id,
|
|
586
|
+
organizationId: record.organizationId,
|
|
587
|
+
tenantId: record.tenantId,
|
|
588
|
+
},
|
|
589
|
+
}),
|
|
590
|
+
phases: [
|
|
591
|
+
async () => {
|
|
592
|
+
const pipelineAssignmentChanged =
|
|
593
|
+
parsed.pipelineId !== undefined || parsed.pipelineStageId !== undefined
|
|
594
|
+
const requestedPipelineStageId =
|
|
595
|
+
parsed.pipelineStageId !== undefined
|
|
596
|
+
? parsed.pipelineStageId ?? null
|
|
597
|
+
: record.pipelineStageId ?? null
|
|
598
|
+
const requestedPipelineId =
|
|
599
|
+
parsed.pipelineId !== undefined ? parsed.pipelineId ?? null : record.pipelineId ?? null
|
|
600
|
+
|
|
601
|
+
nextStageSnapshot = requestedPipelineStageId && (pipelineAssignmentChanged || !record.pipelineStage)
|
|
602
|
+
? await loadPipelineStageSnapshot(em, requestedPipelineStageId, record.tenantId, record.organizationId)
|
|
603
|
+
: null
|
|
604
|
+
if (pipelineAssignmentChanged) {
|
|
605
|
+
nextPipelineAssignment = resolvePipelineAssignment({
|
|
606
|
+
pipelineId: requestedPipelineId,
|
|
607
|
+
pipelineStageId: requestedPipelineStageId,
|
|
608
|
+
stageSnapshot: nextStageSnapshot,
|
|
609
|
+
})
|
|
610
|
+
}
|
|
611
|
+
nextPipelineStageLabel = nextStageSnapshot
|
|
612
|
+
? (await ensureDictionaryEntry(em, {
|
|
613
|
+
tenantId: record.tenantId,
|
|
614
|
+
organizationId: record.organizationId,
|
|
615
|
+
kind: 'pipeline_stage',
|
|
616
|
+
value: nextStageSnapshot.label,
|
|
617
|
+
}))?.value ?? nextStageSnapshot.label
|
|
618
|
+
: null
|
|
619
|
+
resolvedCurrentPipelineStageLabel =
|
|
620
|
+
!nextStageSnapshot && record.pipelineStageId && (parsed.pipelineStageId !== undefined || !record.pipelineStage)
|
|
621
|
+
? await resolvePipelineStageValue(em, record.pipelineStageId, record.tenantId, record.organizationId)
|
|
622
|
+
: null
|
|
623
|
+
},
|
|
624
|
+
() => {
|
|
625
|
+
if (parsed.title !== undefined) record.title = parsed.title
|
|
626
|
+
if (parsed.description !== undefined) record.description = parsed.description ?? null
|
|
627
|
+
if (parsed.status !== undefined) record.status = parsed.status ?? record.status
|
|
628
|
+
if (parsed.pipelineStage !== undefined) record.pipelineStage = parsed.pipelineStage ?? null
|
|
629
|
+
if (parsed.pipelineId !== undefined || (parsed.pipelineStageId !== undefined && nextStageSnapshot)) {
|
|
630
|
+
record.pipelineId = nextPipelineAssignment.pipelineId
|
|
631
|
+
}
|
|
632
|
+
if (parsed.pipelineStageId !== undefined) record.pipelineStageId = nextPipelineAssignment.pipelineStageId
|
|
633
|
+
|
|
634
|
+
if (nextPipelineStageLabel && (parsed.pipelineStageId !== undefined || !record.pipelineStage)) {
|
|
635
|
+
record.pipelineStage = nextPipelineStageLabel
|
|
636
|
+
} else if (resolvedCurrentPipelineStageLabel && (parsed.pipelineStageId !== undefined || !record.pipelineStage)) {
|
|
637
|
+
record.pipelineStage = resolvedCurrentPipelineStageLabel
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (parsed.valueAmount !== undefined) record.valueAmount = toNumericString(parsed.valueAmount)
|
|
641
|
+
if (parsed.valueCurrency !== undefined) record.valueCurrency = parsed.valueCurrency ?? null
|
|
642
|
+
if (parsed.probability !== undefined) record.probability = parsed.probability ?? null
|
|
643
|
+
if (parsed.expectedCloseAt !== undefined) record.expectedCloseAt = parsed.expectedCloseAt ?? null
|
|
644
|
+
if (parsed.ownerUserId !== undefined) record.ownerUserId = parsed.ownerUserId ?? null
|
|
645
|
+
if (parsed.source !== undefined) record.source = parsed.source ?? null
|
|
646
|
+
if (parsed.closureOutcome !== undefined) record.closureOutcome = parsed.closureOutcome ?? null
|
|
647
|
+
if (parsed.lossReasonId !== undefined) record.lossReasonId = parsed.lossReasonId ?? null
|
|
648
|
+
if (parsed.lossNotes !== undefined) record.lossNotes = parsed.lossNotes ?? null
|
|
649
|
+
},
|
|
650
|
+
async () => {
|
|
651
|
+
// CRITICAL: persist the scalar mutations above before any further `em.findOne` / sync
|
|
652
|
+
// helpers run inside this transaction. MikroORM v7's identity-map silently discards
|
|
653
|
+
// pending scalar changes on `record` if a query (such as the stage-transition lookup
|
|
654
|
+
// inside `upsertDealStageTransition`, or the linked-entity finds inside
|
|
655
|
+
// `syncDealPeople` / `syncDealCompanies`) executes on the same `EntityManager`
|
|
656
|
+
// before we explicitly flush. Without this flush, the entire kanban drag-and-drop
|
|
657
|
+
// returns 200 OK but never actually updates `customer_deals` rows — the card
|
|
658
|
+
// snaps back to its source lane on the next refetch (see SPEC-018).
|
|
659
|
+
await em.flush()
|
|
660
|
+
},
|
|
661
|
+
async () => {
|
|
662
|
+
const snapshot = nextStageSnapshot
|
|
663
|
+
if (!snapshot) return
|
|
664
|
+
const shouldRecord =
|
|
665
|
+
parsed.pipelineStageId !== undefined &&
|
|
666
|
+
parsed.pipelineStageId !== null &&
|
|
667
|
+
parsed.pipelineStageId !== previousPipelineStageId
|
|
668
|
+
if (!shouldRecord) return
|
|
669
|
+
await upsertDealStageTransition(em, {
|
|
670
|
+
deal: record,
|
|
671
|
+
pipelineId: snapshot.pipelineId,
|
|
672
|
+
stageId: snapshot.id,
|
|
673
|
+
stageLabel: nextPipelineStageLabel ?? snapshot.label,
|
|
674
|
+
stageOrder: snapshot.order,
|
|
675
|
+
transitionedByUserId: normalizedTransitionAuthorUserId,
|
|
676
|
+
})
|
|
677
|
+
},
|
|
678
|
+
() => syncDealPeople(em, record, parsed.personIds),
|
|
679
|
+
() => syncDealCompanies(em, record, parsed.companyIds),
|
|
680
|
+
],
|
|
686
681
|
})
|
|
687
682
|
|
|
688
683
|
// Emit a lifecycle event for deal won/lost status changes; the notifications
|
|
@@ -118,6 +118,30 @@ export async function setRecordCustomFields(
|
|
|
118
118
|
throw new Error(TOO_MANY_CUSTOM_FIELDS_ERROR)
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// Run the per-key delete+insert work inside ONE database transaction so a
|
|
122
|
+
// multi-value replacement is atomic and isolated. The array branch deletes the
|
|
123
|
+
// existing rows for a key and inserts the replacements; without an enclosing
|
|
124
|
+
// transaction those can land in separate commit boundaries under MikroORM's
|
|
125
|
+
// FlushMode.AUTO (a query elsewhere in the unit auto-flushes part of the work),
|
|
126
|
+
// which intermittently left the field with the delete applied but the inserts
|
|
127
|
+
// missing — the multi-select EDIT reverted to []. The single commit below makes
|
|
128
|
+
// it all-or-nothing. We only open our own transaction when the caller has not
|
|
129
|
+
// already started one (commands fork the request em and may run setCustomFields
|
|
130
|
+
// outside their own withAtomicFlush tx); join an ambient transaction otherwise.
|
|
131
|
+
const txEm = em as {
|
|
132
|
+
begin?: () => Promise<void>
|
|
133
|
+
commit?: () => Promise<void>
|
|
134
|
+
rollback?: () => Promise<void>
|
|
135
|
+
isInTransaction?: () => boolean
|
|
136
|
+
}
|
|
137
|
+
const txCapable =
|
|
138
|
+
typeof txEm.begin === 'function' &&
|
|
139
|
+
typeof txEm.commit === 'function' &&
|
|
140
|
+
typeof txEm.rollback === 'function' &&
|
|
141
|
+
typeof txEm.isInTransaction === 'function'
|
|
142
|
+
const ownCustomFieldTransaction = txCapable && !txEm.isInTransaction!()
|
|
143
|
+
if (ownCustomFieldTransaction) await txEm.begin!()
|
|
144
|
+
try {
|
|
121
145
|
for (const fieldKey of keys) {
|
|
122
146
|
const raw = values[fieldKey]
|
|
123
147
|
if (raw === undefined) continue
|
|
@@ -125,10 +149,19 @@ export async function setRecordCustomFields(
|
|
|
125
149
|
const def = defsByKey?.[fieldKey]
|
|
126
150
|
const encrypted = Boolean(def?.configJson && (def as any).configJson?.encrypted)
|
|
127
151
|
const isArray = Array.isArray(raw)
|
|
128
|
-
// When array:
|
|
152
|
+
// When array (multi-value): replace all existing rows for the key. The old
|
|
153
|
+
// rows are removed via em.remove (a DEFERRED delete keyed by primary id),
|
|
154
|
+
// not nativeDelete, so the DELETE and the replacement INSERTs are applied in
|
|
155
|
+
// the SAME em.flush() — MikroORM wraps a flush in one transaction, making the
|
|
156
|
+
// replacement atomic (the field can never be left empty by a partial
|
|
157
|
+
// failure between delete and insert). It also removes the FlushMode.AUTO
|
|
158
|
+
// footgun the old code had: a nativeDelete issued after em.create()
|
|
159
|
+
// auto-flushed the new rows and then deleted them by fieldKey, wiping the
|
|
160
|
+
// value on EDIT. Regression: TC-CAT-CF-MULTI-EDIT-001 / TC-CRM-CF-MULTI-EDIT-001.
|
|
129
161
|
if (isArray) {
|
|
130
162
|
const arr = raw as Primitive[]
|
|
131
|
-
const
|
|
163
|
+
const existing = await em.find(CustomFieldValue, { entityId, recordId, organizationId, tenantId, fieldKey })
|
|
164
|
+
for (const stale of existing) em.remove(stale)
|
|
132
165
|
for (const val of arr) {
|
|
133
166
|
const col: keyof CustomFieldValue = encrypted ? 'valueText' : def ? columnFromKind(def.kind) : columnFromJsValue(val)
|
|
134
167
|
const cf = em.create(CustomFieldValue, { entityId, recordId, organizationId, tenantId, fieldKey, createdAt: new Date() })
|
|
@@ -144,10 +177,8 @@ export async function setRecordCustomFields(
|
|
|
144
177
|
case 'valueBool': cf.valueBool = stored == null ? null : Boolean(stored); break
|
|
145
178
|
default: cf.valueText = stored == null ? null : String(stored); break
|
|
146
179
|
}
|
|
147
|
-
|
|
180
|
+
toPersist.push(cf)
|
|
148
181
|
}
|
|
149
|
-
await em.nativeDelete(CustomFieldValue, { entityId, recordId, organizationId, tenantId, fieldKey })
|
|
150
|
-
toPersist.push(...replacements)
|
|
151
182
|
continue
|
|
152
183
|
}
|
|
153
184
|
|
|
@@ -186,24 +217,15 @@ export async function setRecordCustomFields(
|
|
|
186
217
|
|
|
187
218
|
if (toPersist.length) em.persist(toPersist)
|
|
188
219
|
await em.flush()
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
if (values[fieldKey] === undefined) continue
|
|
194
|
-
const rows = await conn.execute(
|
|
195
|
-
'select value_text, value_multiline, value_int, value_float, value_bool from custom_field_values where entity_id = ? and record_id = ? and field_key = ? and ((organization_id is null and ? is null) or organization_id = ?) and ((tenant_id is null and ? is null) or tenant_id = ?)',
|
|
196
|
-
[entityId, recordId, fieldKey, organizationId, organizationId, tenantId, tenantId],
|
|
197
|
-
'all',
|
|
198
|
-
) as Array<Record<string, unknown>>
|
|
199
|
-
const persisted = rows.map((row) => row.value_text ?? row.value_multiline ?? row.value_int ?? row.value_float ?? row.value_bool)
|
|
200
|
-
console.warn(`[CF_DEBUG] setRecordCustomFields entityId=${entityId} recordId=${recordId} fieldKey=${fieldKey} input=${JSON.stringify(values[fieldKey])} persistedRows=${rows.length} persisted=${JSON.stringify(persisted)}`)
|
|
201
|
-
}
|
|
202
|
-
} catch (err) {
|
|
203
|
-
console.warn(`[CF_DEBUG] re-query failed: ${(err as Error)?.message ?? String(err)}`)
|
|
220
|
+
if (ownCustomFieldTransaction) await txEm.commit!()
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (ownCustomFieldTransaction) {
|
|
223
|
+
try { await txEm.rollback!() } catch { /* surface the original error, not a rollback failure */ }
|
|
204
224
|
}
|
|
225
|
+
throw err
|
|
205
226
|
}
|
|
206
|
-
// Emit hook for indexing if requested (outside CRUD flows)
|
|
227
|
+
// Emit hook for indexing if requested (outside CRUD flows). Runs AFTER the
|
|
228
|
+
// transaction commits so consumers observe the persisted rows.
|
|
207
229
|
try {
|
|
208
230
|
if (typeof opts.onChanged === 'function') {
|
|
209
231
|
await opts.onChanged({ entityId, recordId, organizationId, tenantId })
|
|
@@ -158,7 +158,7 @@ function scopeEntityIndexes<QB extends { where: (...args: any[]) => QB }>(
|
|
|
158
158
|
|
|
159
159
|
export async function upsertIndexRow(
|
|
160
160
|
em: EntityManager,
|
|
161
|
-
args: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null; searchTokenDoc?: Record<string, unknown> | null }
|
|
161
|
+
args: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null; searchTokenDoc?: Record<string, unknown> | null; deferSearchTokens?: boolean }
|
|
162
162
|
): Promise<UpsertIndexResult> {
|
|
163
163
|
const db = (em as any).getKysely()
|
|
164
164
|
|
|
@@ -172,14 +172,19 @@ export async function upsertIndexRow(
|
|
|
172
172
|
|
|
173
173
|
const doc = await buildIndexDoc(em, args)
|
|
174
174
|
if (!doc) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
175
|
+
// When the caller defers token work it owns the matching token cleanup; the
|
|
176
|
+
// projection-row removal below stays synchronous so list reads converge immediately.
|
|
177
|
+
if (!args.deferSearchTokens) {
|
|
178
|
+
try {
|
|
179
|
+
await reindexSearchTokensForRecord(em, {
|
|
180
|
+
entityType: args.entityType,
|
|
181
|
+
recordId: args.recordId,
|
|
182
|
+
organizationId: args.organizationId ?? null,
|
|
183
|
+
tenantId: args.tenantId ?? null,
|
|
184
|
+
doc: null,
|
|
185
|
+
})
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
183
188
|
if (existed) {
|
|
184
189
|
await scopeEntityIndexes(
|
|
185
190
|
db.deleteFrom('entity_indexes' as any) as any,
|
|
@@ -233,27 +238,69 @@ export async function upsertIndexRow(
|
|
|
233
238
|
|
|
234
239
|
const created = !existed
|
|
235
240
|
const revived = existed && wasDeleted
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
241
|
+
// The search-token rebuild (DELETE + chunked INSERT) is the heavy tail of indexing.
|
|
242
|
+
// Callers that defer it (the upsert subscriber) run `reindexSearchTokensForRecord`
|
|
243
|
+
// asynchronously after this projection update so write latency stays bounded.
|
|
244
|
+
if (!args.deferSearchTokens) {
|
|
245
|
+
try {
|
|
246
|
+
await reindexSearchTokensForRecord(em, {
|
|
247
|
+
entityType: args.entityType,
|
|
248
|
+
recordId: args.recordId,
|
|
249
|
+
organizationId: args.organizationId ?? null,
|
|
250
|
+
tenantId: args.tenantId ?? null,
|
|
242
251
|
doc,
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
252
|
+
searchTokenDoc: args.searchTokenDoc ?? null,
|
|
253
|
+
})
|
|
254
|
+
} catch {}
|
|
255
|
+
}
|
|
256
|
+
return { doc, existed, wasDeleted, created, revived }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Rebuilds (or clears, when `doc` is null) the search-token rows for a single record.
|
|
261
|
+
* This is the asynchronous-friendly tail of `upsertIndexRow`: it does not touch the
|
|
262
|
+
* `entity_indexes` projection that list endpoints read, so it can run out-of-band
|
|
263
|
+
* without making query-index reads inconsistent.
|
|
264
|
+
*/
|
|
265
|
+
export async function reindexSearchTokensForRecord(
|
|
266
|
+
em: EntityManager,
|
|
267
|
+
args: {
|
|
268
|
+
entityType: string
|
|
269
|
+
recordId: string
|
|
270
|
+
organizationId?: string | null
|
|
271
|
+
tenantId?: string | null
|
|
272
|
+
doc: Record<string, any> | null
|
|
273
|
+
searchTokenDoc?: Record<string, unknown> | null
|
|
274
|
+
},
|
|
275
|
+
): Promise<void> {
|
|
276
|
+
const db = (em as any).getKysely()
|
|
277
|
+
if (!args.doc) {
|
|
278
|
+
await deleteSearchTokensForRecord(db, {
|
|
249
279
|
entityType: args.entityType,
|
|
250
280
|
recordId: args.recordId,
|
|
251
281
|
organizationId: args.organizationId ?? null,
|
|
252
282
|
tenantId: args.tenantId ?? null,
|
|
253
|
-
doc: await tokenDoc,
|
|
254
283
|
})
|
|
255
|
-
|
|
256
|
-
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
const tokenDoc = args.searchTokenDoc ?? (() => {
|
|
287
|
+
const encryption = resolveTenantEncryptionService(em as any)
|
|
288
|
+
const dekKeyCache = new Map<string | null, string | null>()
|
|
289
|
+
return decryptIndexDocForSearch(
|
|
290
|
+
args.entityType,
|
|
291
|
+
args.doc,
|
|
292
|
+
{ tenantId: args.tenantId ?? null, organizationId: args.organizationId ?? null },
|
|
293
|
+
encryption,
|
|
294
|
+
dekKeyCache,
|
|
295
|
+
)
|
|
296
|
+
})()
|
|
297
|
+
await replaceSearchTokensForRecord(db, {
|
|
298
|
+
entityType: args.entityType,
|
|
299
|
+
recordId: args.recordId,
|
|
300
|
+
organizationId: args.organizationId ?? null,
|
|
301
|
+
tenantId: args.tenantId ?? null,
|
|
302
|
+
doc: await tokenDoc,
|
|
303
|
+
})
|
|
257
304
|
}
|
|
258
305
|
|
|
259
306
|
export async function markDeleted(
|