@open-mercato/core 0.6.5-develop.4674.1.bf258550ce → 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/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/.turbo/turbo-build.log
CHANGED
package/AGENTS.md
CHANGED
|
@@ -552,6 +552,37 @@ await withAtomicFlush(em, [
|
|
|
552
552
|
await emitCrudSideEffects({ ... })
|
|
553
553
|
```
|
|
554
554
|
|
|
555
|
+
### Preferred: `runCrudCommandWrite` for entity + custom fields + side effects
|
|
556
|
+
|
|
557
|
+
For commands that write an entity, optionally write custom fields, and emit CRUD/index side effects in one logical operation, prefer `runCrudCommandWrite` over composing `withAtomicFlush` + `setCustomFieldsIfAny` + `emitCrudSideEffects` by hand. The helper owns the EM fork, the atomic flush boundary, the custom-field write, and the side-effect queue in the only correct order, and fails closed if any earlier step throws.
|
|
558
|
+
|
|
559
|
+
```typescript
|
|
560
|
+
import { runCrudCommandWrite } from '@open-mercato/shared/lib/commands/runCrudCommandWrite'
|
|
561
|
+
|
|
562
|
+
await runCrudCommandWrite({
|
|
563
|
+
ctx,
|
|
564
|
+
entityId: 'my_module:my_entity',
|
|
565
|
+
action: 'updated',
|
|
566
|
+
scope: { tenantId: record.tenantId, organizationId: record.organizationId },
|
|
567
|
+
customFields: custom,
|
|
568
|
+
events: myCrudEvents,
|
|
569
|
+
indexer: myCrudIndexer,
|
|
570
|
+
sideEffect: () => ({
|
|
571
|
+
entity: record,
|
|
572
|
+
identifiers: { id: record.id, tenantId: record.tenantId, organizationId: record.organizationId },
|
|
573
|
+
}),
|
|
574
|
+
phases: [
|
|
575
|
+
() => {
|
|
576
|
+
record.name = parsed.name
|
|
577
|
+
record.status = parsed.status
|
|
578
|
+
},
|
|
579
|
+
() => syncEntityTags(em, record, parsed.tags),
|
|
580
|
+
],
|
|
581
|
+
})
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
Reference migration: `customers.deals.update` in `packages/core/src/modules/customers/commands/deals.ts`. Keep `withAtomicFlush` for cases the helper doesn't fit (multiple separate transactions per command, etc.).
|
|
585
|
+
|
|
555
586
|
## Profiling
|
|
556
587
|
|
|
557
588
|
- Enable with `OM_PROFILE` env (comma-separated filters: `*`, `all`, `customers.*`, etc.)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
const truthyValues = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
|
|
4
|
+
const falsyValues = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
|
|
5
|
+
let cachedStandaloneEnv = null;
|
|
6
|
+
function parseDotEnv(contents) {
|
|
7
|
+
const values = {};
|
|
8
|
+
for (const line of contents.split(/\r?\n/)) {
|
|
9
|
+
const trimmed = line.trim();
|
|
10
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
11
|
+
const index = trimmed.indexOf("=");
|
|
12
|
+
if (index <= 0) continue;
|
|
13
|
+
const key = trimmed.slice(0, index).trim();
|
|
14
|
+
let value = trimmed.slice(index + 1).trim();
|
|
15
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
16
|
+
value = value.slice(1, -1);
|
|
17
|
+
}
|
|
18
|
+
values[key] = value;
|
|
19
|
+
}
|
|
20
|
+
return values;
|
|
21
|
+
}
|
|
22
|
+
function readStandaloneEnv() {
|
|
23
|
+
if (cachedStandaloneEnv) return cachedStandaloneEnv;
|
|
24
|
+
const appRoot = process.env.OM_TEST_APP_ROOT?.trim();
|
|
25
|
+
if (!appRoot) {
|
|
26
|
+
cachedStandaloneEnv = {};
|
|
27
|
+
return cachedStandaloneEnv;
|
|
28
|
+
}
|
|
29
|
+
const envPath = path.join(appRoot, ".env");
|
|
30
|
+
try {
|
|
31
|
+
cachedStandaloneEnv = parseDotEnv(fs.readFileSync(envPath, "utf8"));
|
|
32
|
+
} catch {
|
|
33
|
+
cachedStandaloneEnv = {};
|
|
34
|
+
}
|
|
35
|
+
return cachedStandaloneEnv;
|
|
36
|
+
}
|
|
37
|
+
function readIntegrationEnv(name) {
|
|
38
|
+
const fromProcess = process.env[name];
|
|
39
|
+
if (typeof fromProcess === "string") return fromProcess;
|
|
40
|
+
return readStandaloneEnv()[name];
|
|
41
|
+
}
|
|
42
|
+
function isStandaloneIntegration() {
|
|
43
|
+
return Boolean(process.env.OM_TEST_APP_ROOT?.trim());
|
|
44
|
+
}
|
|
45
|
+
function readIntegrationEnvFlag(name, defaultValue = false) {
|
|
46
|
+
const raw = readIntegrationEnv(name)?.trim().toLowerCase();
|
|
47
|
+
if (!raw) return defaultValue;
|
|
48
|
+
if (truthyValues.has(raw)) return true;
|
|
49
|
+
if (falsyValues.has(raw)) return false;
|
|
50
|
+
return defaultValue;
|
|
51
|
+
}
|
|
52
|
+
export {
|
|
53
|
+
isStandaloneIntegration,
|
|
54
|
+
readIntegrationEnv,
|
|
55
|
+
readIntegrationEnvFlag,
|
|
56
|
+
readStandaloneEnv
|
|
57
|
+
};
|
|
58
|
+
//# sourceMappingURL=standaloneEnv.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/helpers/integration/standaloneEnv.ts"],
|
|
4
|
+
"sourcesContent": ["import fs from 'fs';\nimport path from 'path';\n\nconst truthyValues = new Set(['1', 'true', 'yes', 'on']);\nconst falsyValues = new Set(['0', 'false', 'no', 'off']);\n\nlet cachedStandaloneEnv: Record<string, string> | null = null;\n\nfunction parseDotEnv(contents: string): Record<string, string> {\n const values: Record<string, string> = {};\n for (const line of contents.split(/\\r?\\n/)) {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith('#')) continue;\n const index = trimmed.indexOf('=');\n if (index <= 0) continue;\n const key = trimmed.slice(0, index).trim();\n let value = trimmed.slice(index + 1).trim();\n if (\n (value.startsWith('\"') && value.endsWith('\"')) ||\n (value.startsWith(\"'\") && value.endsWith(\"'\"))\n ) {\n value = value.slice(1, -1);\n }\n values[key] = value;\n }\n return values;\n}\n\nexport function readStandaloneEnv(): Record<string, string> {\n if (cachedStandaloneEnv) return cachedStandaloneEnv;\n const appRoot = process.env.OM_TEST_APP_ROOT?.trim();\n if (!appRoot) {\n cachedStandaloneEnv = {};\n return cachedStandaloneEnv;\n }\n\n const envPath = path.join(appRoot, '.env');\n try {\n cachedStandaloneEnv = parseDotEnv(fs.readFileSync(envPath, 'utf8'));\n } catch {\n cachedStandaloneEnv = {};\n }\n return cachedStandaloneEnv;\n}\n\nexport function readIntegrationEnv(name: string): string | undefined {\n const fromProcess = process.env[name];\n if (typeof fromProcess === 'string') return fromProcess;\n return readStandaloneEnv()[name];\n}\n\nexport function isStandaloneIntegration(): boolean {\n return Boolean(process.env.OM_TEST_APP_ROOT?.trim());\n}\n\nexport function readIntegrationEnvFlag(name: string, defaultValue = false): boolean {\n const raw = readIntegrationEnv(name)?.trim().toLowerCase();\n if (!raw) return defaultValue;\n if (truthyValues.has(raw)) return true;\n if (falsyValues.has(raw)) return false;\n return defaultValue;\n}\n"],
|
|
5
|
+
"mappings": "AAAA,OAAO,QAAQ;AACf,OAAO,UAAU;AAEjB,MAAM,eAAe,oBAAI,IAAI,CAAC,KAAK,QAAQ,OAAO,IAAI,CAAC;AACvD,MAAM,cAAc,oBAAI,IAAI,CAAC,KAAK,SAAS,MAAM,KAAK,CAAC;AAEvD,IAAI,sBAAqD;AAEzD,SAAS,YAAY,UAA0C;AAC7D,QAAM,SAAiC,CAAC;AACxC,aAAW,QAAQ,SAAS,MAAM,OAAO,GAAG;AAC1C,UAAM,UAAU,KAAK,KAAK;AAC1B,QAAI,CAAC,WAAW,QAAQ,WAAW,GAAG,EAAG;AACzC,UAAM,QAAQ,QAAQ,QAAQ,GAAG;AACjC,QAAI,SAAS,EAAG;AAChB,UAAM,MAAM,QAAQ,MAAM,GAAG,KAAK,EAAE,KAAK;AACzC,QAAI,QAAQ,QAAQ,MAAM,QAAQ,CAAC,EAAE,KAAK;AAC1C,QACG,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,KAC3C,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,GAC5C;AACA,cAAQ,MAAM,MAAM,GAAG,EAAE;AAAA,IAC3B;AACA,WAAO,GAAG,IAAI;AAAA,EAChB;AACA,SAAO;AACT;AAEO,SAAS,oBAA4C;AAC1D,MAAI,oBAAqB,QAAO;AAChC,QAAM,UAAU,QAAQ,IAAI,kBAAkB,KAAK;AACnD,MAAI,CAAC,SAAS;AACZ,0BAAsB,CAAC;AACvB,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,KAAK,KAAK,SAAS,MAAM;AACzC,MAAI;AACF,0BAAsB,YAAY,GAAG,aAAa,SAAS,MAAM,CAAC;AAAA,EACpE,QAAQ;AACN,0BAAsB,CAAC;AAAA,EACzB;AACA,SAAO;AACT;AAEO,SAAS,mBAAmB,MAAkC;AACnE,QAAM,cAAc,QAAQ,IAAI,IAAI;AACpC,MAAI,OAAO,gBAAgB,SAAU,QAAO;AAC5C,SAAO,kBAAkB,EAAE,IAAI;AACjC;AAEO,SAAS,0BAAmC;AACjD,SAAO,QAAQ,QAAQ,IAAI,kBAAkB,KAAK,CAAC;AACrD;AAEO,SAAS,uBAAuB,MAAc,eAAe,OAAgB;AAClF,QAAM,MAAM,mBAAmB,IAAI,GAAG,KAAK,EAAE,YAAY;AACzD,MAAI,CAAC,IAAK,QAAO;AACjB,MAAI,aAAa,IAAI,GAAG,EAAG,QAAO;AAClC,MAAI,YAAY,IAAI,GAAG,EAAG,QAAO;AACjC,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
import { expect } from "@playwright/test";
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
|
|
2
3
|
import { apiRequest } from "./api.js";
|
|
4
|
+
import { expectId, readJsonSafe } from "./generalFixtures.js";
|
|
3
5
|
const HEADER_PREFIX = "omop:";
|
|
4
6
|
const UNDO_PATH = "/api/audit_logs/audit-logs/actions/undo";
|
|
5
7
|
const REDO_PATH = "/api/audit_logs/audit-logs/actions/redo";
|
|
6
8
|
const ACTIONS_PATH = "/api/audit_logs/audit-logs/actions";
|
|
9
|
+
const UNDO_TESTS_DISABLED_ENV = "OM_INTEGRATION_UNDO_TESTS_DISABLED";
|
|
10
|
+
function undoTestsDisabled(env = process.env) {
|
|
11
|
+
return parseBooleanWithDefault(env[UNDO_TESTS_DISABLED_ENV], false);
|
|
12
|
+
}
|
|
13
|
+
function skipIfUndoTestsDisabled() {
|
|
14
|
+
test.skip(undoTestsDisabled(), `${UNDO_TESTS_DISABLED_ENV} is set \u2014 undo/redo integration tests skipped`);
|
|
15
|
+
}
|
|
7
16
|
function extractOperation(response) {
|
|
8
17
|
const header = response.headers()["x-om-operation"];
|
|
9
18
|
if (!header || typeof header !== "string") return null;
|
|
@@ -67,7 +76,90 @@ function assertFieldsEqual(actual, expected, fields, context) {
|
|
|
67
76
|
).toBe(JSON.stringify(expected[field]));
|
|
68
77
|
}
|
|
69
78
|
}
|
|
79
|
+
function findRecord(body, id) {
|
|
80
|
+
if (!body || typeof body !== "object") return null;
|
|
81
|
+
if (!Array.isArray(body) && body.id === id) {
|
|
82
|
+
return body;
|
|
83
|
+
}
|
|
84
|
+
for (const value of Array.isArray(body) ? body : Object.values(body)) {
|
|
85
|
+
const found = findRecord(value, id);
|
|
86
|
+
if (found) return found;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
async function readRecord(request, token, entity, id) {
|
|
91
|
+
const path = entity.readPath?.(id) ?? `${entity.collectionPath}?id=${encodeURIComponent(id)}`;
|
|
92
|
+
const response = await apiRequest(request, "GET", path, { token });
|
|
93
|
+
const body = await readJsonSafe(response);
|
|
94
|
+
if (!response.ok()) return null;
|
|
95
|
+
return findRecord(body, id);
|
|
96
|
+
}
|
|
97
|
+
function fieldValue(record, field) {
|
|
98
|
+
return record?.[field];
|
|
99
|
+
}
|
|
100
|
+
async function deleteEntity(request, token, entity, id) {
|
|
101
|
+
const path = entity.deletePath?.(id) ?? `${entity.collectionPath}?id=${encodeURIComponent(id)}`;
|
|
102
|
+
return apiRequest(request, "DELETE", path, { token });
|
|
103
|
+
}
|
|
104
|
+
async function runCrudUndoRoundTrip(request, token, entity) {
|
|
105
|
+
const stamp = `${Date.now()}${Math.floor(Math.random() * 1e3)}`;
|
|
106
|
+
let createUndoId = null;
|
|
107
|
+
let cycleId = null;
|
|
108
|
+
try {
|
|
109
|
+
const createUndoRes = await apiRequest(request, "POST", entity.collectionPath, {
|
|
110
|
+
token,
|
|
111
|
+
data: entity.createPayload(`${stamp}a`)
|
|
112
|
+
});
|
|
113
|
+
expect(createUndoRes.status(), `${entity.label} create-for-undo status`).toBe(entity.createStatus ?? 201);
|
|
114
|
+
const createUndoOp = expectOperation(createUndoRes, `${entity.label}.create`);
|
|
115
|
+
createUndoId = createUndoOp.resourceId || expectId((await readJsonSafe(createUndoRes))?.id, `${entity.label} create id`);
|
|
116
|
+
expect(fieldValue(await readRecord(request, token, entity, createUndoId), entity.field), `${entity.label} field readable after create`).toBeDefined();
|
|
117
|
+
await undoOk(request, token, createUndoOp.undoToken, `${entity.label} undo create`);
|
|
118
|
+
expect(await readRecord(request, token, entity, createUndoId), `${entity.label} create\u2192undo soft-deletes/removes the record (I3)`).toBeNull();
|
|
119
|
+
await expectTokenConsumed(request, token, createUndoOp.undoToken, `${entity.label} create token consumed (I5)`);
|
|
120
|
+
const createRes = await apiRequest(request, "POST", entity.collectionPath, {
|
|
121
|
+
token,
|
|
122
|
+
data: entity.createPayload(`${stamp}b`)
|
|
123
|
+
});
|
|
124
|
+
expect(createRes.status(), `${entity.label} create status`).toBe(entity.createStatus ?? 201);
|
|
125
|
+
const createOp = expectOperation(createRes, `${entity.label}.create`);
|
|
126
|
+
cycleId = createOp.resourceId || expectId((await readJsonSafe(createRes))?.id, `${entity.label} cycle id`);
|
|
127
|
+
const beforeUpdate = await readRecord(request, token, entity, cycleId);
|
|
128
|
+
const beforeValue = fieldValue(beforeUpdate, entity.field);
|
|
129
|
+
expect(beforeValue, `${entity.label} field readable before update`).toBeDefined();
|
|
130
|
+
const updateRes = await apiRequest(request, "PUT", entity.collectionPath, {
|
|
131
|
+
token,
|
|
132
|
+
data: entity.updatePayload(cycleId, stamp)
|
|
133
|
+
});
|
|
134
|
+
expect(updateRes.status(), `${entity.label} update status`).toBe(entity.updateStatus ?? 200);
|
|
135
|
+
const updateOp = expectOperation(updateRes, `${entity.label}.update`);
|
|
136
|
+
const afterUpdate = await readRecord(request, token, entity, cycleId);
|
|
137
|
+
const afterUpdateValue = fieldValue(afterUpdate, entity.field);
|
|
138
|
+
expect(JSON.stringify(afterUpdateValue), `${entity.label} field changed by update`).not.toBe(JSON.stringify(beforeValue));
|
|
139
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
140
|
+
await undoOk(request, token, updateOp.undoToken, `${entity.label} undo update`);
|
|
141
|
+
const afterUndo = await readRecord(request, token, entity, cycleId);
|
|
142
|
+
expect(JSON.stringify(fieldValue(afterUndo, entity.field)), `${entity.label} update\u2192undo restores ${entity.field} (I1)`).toBe(JSON.stringify(beforeValue));
|
|
143
|
+
if (typeof beforeUpdate?.updatedAt === "string" && typeof afterUndo?.updatedAt === "string") {
|
|
144
|
+
expect(afterUndo.updatedAt, `${entity.label} undo bumps updatedAt`).not.toBe(beforeUpdate.updatedAt);
|
|
145
|
+
}
|
|
146
|
+
await redoOk(request, token, updateOp.logId, `${entity.label} redo update`);
|
|
147
|
+
expect(JSON.stringify(fieldValue(await readRecord(request, token, entity, cycleId), entity.field)), `${entity.label} redo re-applies update (I6)`).toBe(JSON.stringify(afterUpdateValue));
|
|
148
|
+
const deleteRes = await deleteEntity(request, token, entity, cycleId);
|
|
149
|
+
expect(deleteRes.ok(), `${entity.label} delete status ${deleteRes.status()}`).toBeTruthy();
|
|
150
|
+
const deleteOp = expectOperation(deleteRes, `${entity.label}.delete`);
|
|
151
|
+
expect(await readRecord(request, token, entity, cycleId), `${entity.label} deleted record should not read`).toBeNull();
|
|
152
|
+
await undoOk(request, token, deleteOp.undoToken, `${entity.label} undo delete`);
|
|
153
|
+
expect(fieldValue(await readRecord(request, token, entity, cycleId), entity.field), `${entity.label} delete\u2192undo re-materializes (I2)`).toBeDefined();
|
|
154
|
+
} finally {
|
|
155
|
+
if (createUndoId) await deleteEntity(request, token, entity, createUndoId).catch(() => {
|
|
156
|
+
});
|
|
157
|
+
if (cycleId) await deleteEntity(request, token, entity, cycleId).catch(() => {
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
70
161
|
export {
|
|
162
|
+
UNDO_TESTS_DISABLED_ENV,
|
|
71
163
|
assertFieldsEqual,
|
|
72
164
|
expectOperation,
|
|
73
165
|
expectTokenConsumed,
|
|
@@ -75,7 +167,10 @@ export {
|
|
|
75
167
|
listUndoable,
|
|
76
168
|
redoByLogId,
|
|
77
169
|
redoOk,
|
|
170
|
+
runCrudUndoRoundTrip,
|
|
171
|
+
skipIfUndoTestsDisabled,
|
|
78
172
|
undoByToken,
|
|
79
|
-
undoOk
|
|
173
|
+
undoOk,
|
|
174
|
+
undoTestsDisabled
|
|
80
175
|
};
|
|
81
176
|
//# sourceMappingURL=undoHarness.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/helpers/integration/undoHarness.ts"],
|
|
4
|
-
"sourcesContent": ["import { type APIRequestContext, type APIResponse, expect } from '@playwright/test'\nimport { apiRequest } from './api'\n\n/**\n * Shared harness for verifying Undo/Redo correctness against the real command bus.\n *\n * Every mutating Open Mercato API response carries the operation metadata in the\n * `x-om-operation` header (`omop:<urlencoded JSON>`) containing the `undoToken` and the\n * audit log `id` (used as `logId` for redo). These helpers extract that envelope and drive\n * the real undo/redo endpoints so tests can assert full state restoration per TC-UNDO-001.\n */\n\nconst HEADER_PREFIX = 'omop:'\nconst UNDO_PATH = '/api/audit_logs/audit-logs/actions/undo'\nconst REDO_PATH = '/api/audit_logs/audit-logs/actions/redo'\nconst ACTIONS_PATH = '/api/audit_logs/audit-logs/actions'\n\nexport type Operation = {\n logId: string\n undoToken: string\n commandId: string\n resourceKind: string | null\n resourceId: string | null\n}\n\n/** Parse the `x-om-operation` header into a structured operation, or null when absent/malformed. */\nexport function extractOperation(response: APIResponse): Operation | null {\n const header = response.headers()['x-om-operation']\n if (!header || typeof header !== 'string') return null\n const trimmed = header.startsWith(HEADER_PREFIX) ? header.slice(HEADER_PREFIX.length) : header\n try {\n const parsed = JSON.parse(decodeURIComponent(trimmed)) as Record<string, unknown>\n if (typeof parsed.id !== 'string' || typeof parsed.commandId !== 'string') return null\n if (typeof parsed.undoToken !== 'string' || !parsed.undoToken) return null\n return {\n logId: parsed.id,\n undoToken: parsed.undoToken,\n commandId: parsed.commandId,\n resourceKind: (parsed.resourceKind as string) ?? null,\n resourceId: (parsed.resourceId as string) ?? null,\n }\n } catch {\n return null\n }\n}\n\n/** Like extractOperation but fails the test if no undo token was issued. */\nexport function expectOperation(response: APIResponse, context: string): Operation {\n const op = extractOperation(response)\n expect(op, `Expected an undo token (x-om-operation header) for ${context}, got none`).toBeTruthy()\n return op as Operation\n}\n\nexport async function undoByToken(request: APIRequestContext, token: string, undoToken: string): Promise<APIResponse> {\n return apiRequest(request, 'POST', UNDO_PATH, { token, data: { undoToken } })\n}\n\nexport async function redoByLogId(request: APIRequestContext, token: string, logId: string): Promise<APIResponse> {\n return apiRequest(request, 'POST', REDO_PATH, { token, data: { logId } })\n}\n\n/** Undo and assert success; returns the resolved logId. */\nexport async function undoOk(request: APIRequestContext, token: string, undoToken: string, context: string): Promise<string> {\n const res = await undoByToken(request, token, undoToken)\n const body = (await res.json().catch(() => null)) as { ok?: boolean; logId?: string } | null\n expect(res.ok(), `Undo failed for ${context}: status ${res.status()} body ${JSON.stringify(body)}`).toBeTruthy()\n expect(body?.ok, `Undo not ok for ${context}: ${JSON.stringify(body)}`).toBeTruthy()\n return body?.logId as string\n}\n\n/** Redo and assert success; returns the new operation (new undoToken + logId). */\nexport async function redoOk(request: APIRequestContext, token: string, logId: string, context: string): Promise<{ logId: string; undoToken: string | null }> {\n const res = await redoByLogId(request, token, logId)\n const body = (await res.json().catch(() => null)) as { ok?: boolean; logId?: string; undoToken?: string } | null\n expect(res.ok(), `Redo failed for ${context}: status ${res.status()} body ${JSON.stringify(body)}`).toBeTruthy()\n expect(body?.ok, `Redo not ok for ${context}: ${JSON.stringify(body)}`).toBeTruthy()\n return { logId: body?.logId as string, undoToken: body?.undoToken ?? null }\n}\n\n/** Assert that undoing an already-consumed token is rejected (token consumption / no double-undo). */\nexport async function expectTokenConsumed(request: APIRequestContext, token: string, undoToken: string, context: string): Promise<void> {\n const res = await undoByToken(request, token, undoToken)\n expect(res.ok(), `Expected double-undo to be rejected for ${context}, but it succeeded`).toBeFalsy()\n}\n\n/** Fetch undoable actions list (for Version History assertions). */\nexport async function listUndoable(request: APIRequestContext, token: string, params: Record<string, string> = {}): Promise<unknown> {\n const qs = new URLSearchParams({ undoableOnly: 'true', ...params }).toString()\n const res = await apiRequest(request, 'GET', `${ACTIONS_PATH}?${qs}`, { token })\n return res.json().catch(() => null)\n}\n\n/**\n * Deep-equality assertion for a selected set of fields between two entity snapshots.\n * Reports the first mismatching field with context for clear bug triage.\n */\nexport function assertFieldsEqual(\n actual: Record<string, unknown> | null | undefined,\n expected: Record<string, unknown> | null | undefined,\n fields: string[],\n context: string,\n): void {\n expect(actual, `${context}: actual entity missing`).toBeTruthy()\n expect(expected, `${context}: expected entity missing`).toBeTruthy()\n for (const field of fields) {\n expect(\n JSON.stringify((actual as Record<string, unknown>)[field]),\n `${context}: field \"${field}\" not restored (expected ${JSON.stringify((expected as Record<string, unknown>)[field])}, got ${JSON.stringify((actual as Record<string, unknown>)[field])})`,\n ).toBe(JSON.stringify((expected as Record<string, unknown>)[field]))\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAmD,
|
|
4
|
+
"sourcesContent": ["import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { apiRequest } from './api'\nimport { expectId, readJsonSafe } from './generalFixtures'\n\n/**\n * Shared harness for verifying Undo/Redo correctness against the real command bus.\n *\n * Every mutating Open Mercato API response carries the operation metadata in the\n * `x-om-operation` header (`omop:<urlencoded JSON>`) containing the `undoToken` and the\n * audit log `id` (used as `logId` for redo). These helpers extract that envelope and drive\n * the real undo/redo endpoints so tests can assert full state restoration per TC-UNDO-001.\n */\n\nconst HEADER_PREFIX = 'omop:'\nconst UNDO_PATH = '/api/audit_logs/audit-logs/actions/undo'\nconst REDO_PATH = '/api/audit_logs/audit-logs/actions/redo'\nconst ACTIONS_PATH = '/api/audit_logs/audit-logs/actions'\nexport const UNDO_TESTS_DISABLED_ENV = 'OM_INTEGRATION_UNDO_TESTS_DISABLED'\n\nexport type Operation = {\n logId: string\n undoToken: string\n commandId: string\n resourceKind: string | null\n resourceId: string | null\n}\n\nexport type CrudUndoEntityConfig = {\n label: string\n collectionPath: string\n field: string\n createPayload: (stamp: string) => Record<string, unknown>\n updatePayload: (id: string, stamp: string) => Record<string, unknown>\n readPath?: (id: string) => string\n deletePath?: (id: string) => string\n createStatus?: number\n updateStatus?: number\n}\n\nexport function undoTestsDisabled(env: NodeJS.ProcessEnv = process.env): boolean {\n return parseBooleanWithDefault(env[UNDO_TESTS_DISABLED_ENV], false)\n}\n\nexport function skipIfUndoTestsDisabled(): void {\n test.skip(undoTestsDisabled(), `${UNDO_TESTS_DISABLED_ENV} is set \u2014 undo/redo integration tests skipped`)\n}\n\n/** Parse the `x-om-operation` header into a structured operation, or null when absent/malformed. */\nexport function extractOperation(response: APIResponse): Operation | null {\n const header = response.headers()['x-om-operation']\n if (!header || typeof header !== 'string') return null\n const trimmed = header.startsWith(HEADER_PREFIX) ? header.slice(HEADER_PREFIX.length) : header\n try {\n const parsed = JSON.parse(decodeURIComponent(trimmed)) as Record<string, unknown>\n if (typeof parsed.id !== 'string' || typeof parsed.commandId !== 'string') return null\n if (typeof parsed.undoToken !== 'string' || !parsed.undoToken) return null\n return {\n logId: parsed.id,\n undoToken: parsed.undoToken,\n commandId: parsed.commandId,\n resourceKind: (parsed.resourceKind as string) ?? null,\n resourceId: (parsed.resourceId as string) ?? null,\n }\n } catch {\n return null\n }\n}\n\n/** Like extractOperation but fails the test if no undo token was issued. */\nexport function expectOperation(response: APIResponse, context: string): Operation {\n const op = extractOperation(response)\n expect(op, `Expected an undo token (x-om-operation header) for ${context}, got none`).toBeTruthy()\n return op as Operation\n}\n\nexport async function undoByToken(request: APIRequestContext, token: string, undoToken: string): Promise<APIResponse> {\n return apiRequest(request, 'POST', UNDO_PATH, { token, data: { undoToken } })\n}\n\nexport async function redoByLogId(request: APIRequestContext, token: string, logId: string): Promise<APIResponse> {\n return apiRequest(request, 'POST', REDO_PATH, { token, data: { logId } })\n}\n\n/** Undo and assert success; returns the resolved logId. */\nexport async function undoOk(request: APIRequestContext, token: string, undoToken: string, context: string): Promise<string> {\n const res = await undoByToken(request, token, undoToken)\n const body = (await res.json().catch(() => null)) as { ok?: boolean; logId?: string } | null\n expect(res.ok(), `Undo failed for ${context}: status ${res.status()} body ${JSON.stringify(body)}`).toBeTruthy()\n expect(body?.ok, `Undo not ok for ${context}: ${JSON.stringify(body)}`).toBeTruthy()\n return body?.logId as string\n}\n\n/** Redo and assert success; returns the new operation (new undoToken + logId). */\nexport async function redoOk(request: APIRequestContext, token: string, logId: string, context: string): Promise<{ logId: string; undoToken: string | null }> {\n const res = await redoByLogId(request, token, logId)\n const body = (await res.json().catch(() => null)) as { ok?: boolean; logId?: string; undoToken?: string } | null\n expect(res.ok(), `Redo failed for ${context}: status ${res.status()} body ${JSON.stringify(body)}`).toBeTruthy()\n expect(body?.ok, `Redo not ok for ${context}: ${JSON.stringify(body)}`).toBeTruthy()\n return { logId: body?.logId as string, undoToken: body?.undoToken ?? null }\n}\n\n/** Assert that undoing an already-consumed token is rejected (token consumption / no double-undo). */\nexport async function expectTokenConsumed(request: APIRequestContext, token: string, undoToken: string, context: string): Promise<void> {\n const res = await undoByToken(request, token, undoToken)\n expect(res.ok(), `Expected double-undo to be rejected for ${context}, but it succeeded`).toBeFalsy()\n}\n\n/** Fetch undoable actions list (for Version History assertions). */\nexport async function listUndoable(request: APIRequestContext, token: string, params: Record<string, string> = {}): Promise<unknown> {\n const qs = new URLSearchParams({ undoableOnly: 'true', ...params }).toString()\n const res = await apiRequest(request, 'GET', `${ACTIONS_PATH}?${qs}`, { token })\n return res.json().catch(() => null)\n}\n\n/**\n * Deep-equality assertion for a selected set of fields between two entity snapshots.\n * Reports the first mismatching field with context for clear bug triage.\n */\nexport function assertFieldsEqual(\n actual: Record<string, unknown> | null | undefined,\n expected: Record<string, unknown> | null | undefined,\n fields: string[],\n context: string,\n): void {\n expect(actual, `${context}: actual entity missing`).toBeTruthy()\n expect(expected, `${context}: expected entity missing`).toBeTruthy()\n for (const field of fields) {\n expect(\n JSON.stringify((actual as Record<string, unknown>)[field]),\n `${context}: field \"${field}\" not restored (expected ${JSON.stringify((expected as Record<string, unknown>)[field])}, got ${JSON.stringify((actual as Record<string, unknown>)[field])})`,\n ).toBe(JSON.stringify((expected as Record<string, unknown>)[field]))\n }\n}\n\nfunction findRecord(body: unknown, id: string): Record<string, unknown> | null {\n if (!body || typeof body !== 'object') return null\n if (!Array.isArray(body) && (body as Record<string, unknown>).id === id) {\n return body as Record<string, unknown>\n }\n for (const value of Array.isArray(body) ? body : Object.values(body)) {\n const found = findRecord(value, id)\n if (found) return found\n }\n return null\n}\n\nasync function readRecord(\n request: APIRequestContext,\n token: string,\n entity: CrudUndoEntityConfig,\n id: string,\n): Promise<Record<string, unknown> | null> {\n const path = entity.readPath?.(id) ?? `${entity.collectionPath}?id=${encodeURIComponent(id)}`\n const response = await apiRequest(request, 'GET', path, { token })\n const body = await readJsonSafe(response)\n if (!response.ok()) return null\n return findRecord(body, id)\n}\n\nfunction fieldValue(record: Record<string, unknown> | null, field: string): unknown {\n return record?.[field]\n}\n\nasync function deleteEntity(\n request: APIRequestContext,\n token: string,\n entity: CrudUndoEntityConfig,\n id: string,\n): Promise<APIResponse> {\n const path = entity.deletePath?.(id) ?? `${entity.collectionPath}?id=${encodeURIComponent(id)}`\n return apiRequest(request, 'DELETE', path, { token })\n}\n\nexport async function runCrudUndoRoundTrip(\n request: APIRequestContext,\n token: string,\n entity: CrudUndoEntityConfig,\n): Promise<void> {\n const stamp = `${Date.now()}${Math.floor(Math.random() * 1000)}`\n let createUndoId: string | null = null\n let cycleId: string | null = null\n\n try {\n const createUndoRes = await apiRequest(request, 'POST', entity.collectionPath, {\n token,\n data: entity.createPayload(`${stamp}a`),\n })\n expect(createUndoRes.status(), `${entity.label} create-for-undo status`).toBe(entity.createStatus ?? 201)\n const createUndoOp = expectOperation(createUndoRes, `${entity.label}.create`)\n createUndoId = createUndoOp.resourceId || expectId((await readJsonSafe<Record<string, unknown>>(createUndoRes))?.id, `${entity.label} create id`)\n expect(fieldValue(await readRecord(request, token, entity, createUndoId), entity.field), `${entity.label} field readable after create`).toBeDefined()\n\n await undoOk(request, token, createUndoOp.undoToken, `${entity.label} undo create`)\n expect(await readRecord(request, token, entity, createUndoId), `${entity.label} create\u2192undo soft-deletes/removes the record (I3)`).toBeNull()\n await expectTokenConsumed(request, token, createUndoOp.undoToken, `${entity.label} create token consumed (I5)`)\n\n const createRes = await apiRequest(request, 'POST', entity.collectionPath, {\n token,\n data: entity.createPayload(`${stamp}b`),\n })\n expect(createRes.status(), `${entity.label} create status`).toBe(entity.createStatus ?? 201)\n const createOp = expectOperation(createRes, `${entity.label}.create`)\n cycleId = createOp.resourceId || expectId((await readJsonSafe<Record<string, unknown>>(createRes))?.id, `${entity.label} cycle id`)\n\n const beforeUpdate = await readRecord(request, token, entity, cycleId)\n const beforeValue = fieldValue(beforeUpdate, entity.field)\n expect(beforeValue, `${entity.label} field readable before update`).toBeDefined()\n\n const updateRes = await apiRequest(request, 'PUT', entity.collectionPath, {\n token,\n data: entity.updatePayload(cycleId, stamp),\n })\n expect(updateRes.status(), `${entity.label} update status`).toBe(entity.updateStatus ?? 200)\n const updateOp = expectOperation(updateRes, `${entity.label}.update`)\n const afterUpdate = await readRecord(request, token, entity, cycleId)\n const afterUpdateValue = fieldValue(afterUpdate, entity.field)\n expect(JSON.stringify(afterUpdateValue), `${entity.label} field changed by update`).not.toBe(JSON.stringify(beforeValue))\n\n await new Promise((resolve) => setTimeout(resolve, 10))\n await undoOk(request, token, updateOp.undoToken, `${entity.label} undo update`)\n const afterUndo = await readRecord(request, token, entity, cycleId)\n expect(JSON.stringify(fieldValue(afterUndo, entity.field)), `${entity.label} update\u2192undo restores ${entity.field} (I1)`).toBe(JSON.stringify(beforeValue))\n if (typeof beforeUpdate?.updatedAt === 'string' && typeof afterUndo?.updatedAt === 'string') {\n expect(afterUndo.updatedAt, `${entity.label} undo bumps updatedAt`).not.toBe(beforeUpdate.updatedAt)\n }\n\n await redoOk(request, token, updateOp.logId, `${entity.label} redo update`)\n expect(JSON.stringify(fieldValue(await readRecord(request, token, entity, cycleId), entity.field)), `${entity.label} redo re-applies update (I6)`).toBe(JSON.stringify(afterUpdateValue))\n\n const deleteRes = await deleteEntity(request, token, entity, cycleId)\n expect(deleteRes.ok(), `${entity.label} delete status ${deleteRes.status()}`).toBeTruthy()\n const deleteOp = expectOperation(deleteRes, `${entity.label}.delete`)\n expect(await readRecord(request, token, entity, cycleId), `${entity.label} deleted record should not read`).toBeNull()\n\n await undoOk(request, token, deleteOp.undoToken, `${entity.label} undo delete`)\n expect(fieldValue(await readRecord(request, token, entity, cycleId), entity.field), `${entity.label} delete\u2192undo re-materializes (I2)`).toBeDefined()\n } finally {\n if (createUndoId) await deleteEntity(request, token, entity, createUndoId).catch(() => {})\n if (cycleId) await deleteEntity(request, token, entity, cycleId).catch(() => {})\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
normalizeAuthorUserId
|
|
9
9
|
} from "@open-mercato/shared/lib/commands/helpers";
|
|
10
10
|
import { withAtomicFlush } from "@open-mercato/shared/lib/commands/flush";
|
|
11
|
+
import { runCrudCommandWrite } from "@open-mercato/shared/lib/commands/runCrudCommandWrite";
|
|
11
12
|
import {
|
|
12
13
|
CustomerDeal,
|
|
13
14
|
CustomerDealPersonLink,
|
|
@@ -437,92 +438,88 @@ const updateDealCommand = {
|
|
|
437
438
|
};
|
|
438
439
|
let nextPipelineStageLabel = null;
|
|
439
440
|
let resolvedCurrentPipelineStageLabel = null;
|
|
440
|
-
await
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const requestedPipelineStageId = parsed.pipelineStageId !== void 0 ? parsed.pipelineStageId ?? null : record.pipelineStageId ?? null;
|
|
444
|
-
const requestedPipelineId = parsed.pipelineId !== void 0 ? parsed.pipelineId ?? null : record.pipelineId ?? null;
|
|
445
|
-
nextStageSnapshot = requestedPipelineStageId && (pipelineAssignmentChanged || !record.pipelineStage) ? await loadPipelineStageSnapshot(em, requestedPipelineStageId, record.tenantId, record.organizationId) : null;
|
|
446
|
-
if (pipelineAssignmentChanged) {
|
|
447
|
-
nextPipelineAssignment = resolvePipelineAssignment({
|
|
448
|
-
pipelineId: requestedPipelineId,
|
|
449
|
-
pipelineStageId: requestedPipelineStageId,
|
|
450
|
-
stageSnapshot: nextStageSnapshot
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
nextPipelineStageLabel = nextStageSnapshot ? (await ensureDictionaryEntry(em, {
|
|
454
|
-
tenantId: record.tenantId,
|
|
455
|
-
organizationId: record.organizationId,
|
|
456
|
-
kind: "pipeline_stage",
|
|
457
|
-
value: nextStageSnapshot.label
|
|
458
|
-
}))?.value ?? nextStageSnapshot.label : null;
|
|
459
|
-
resolvedCurrentPipelineStageLabel = !nextStageSnapshot && record.pipelineStageId && (parsed.pipelineStageId !== void 0 || !record.pipelineStage) ? await resolvePipelineStageValue(em, record.pipelineStageId, record.tenantId, record.organizationId) : null;
|
|
460
|
-
},
|
|
461
|
-
() => {
|
|
462
|
-
if (parsed.title !== void 0) record.title = parsed.title;
|
|
463
|
-
if (parsed.description !== void 0) record.description = parsed.description ?? null;
|
|
464
|
-
if (parsed.status !== void 0) record.status = parsed.status ?? record.status;
|
|
465
|
-
if (parsed.pipelineStage !== void 0) record.pipelineStage = parsed.pipelineStage ?? null;
|
|
466
|
-
if (parsed.pipelineId !== void 0 || parsed.pipelineStageId !== void 0 && nextStageSnapshot) {
|
|
467
|
-
record.pipelineId = nextPipelineAssignment.pipelineId;
|
|
468
|
-
}
|
|
469
|
-
if (parsed.pipelineStageId !== void 0) record.pipelineStageId = nextPipelineAssignment.pipelineStageId;
|
|
470
|
-
if (nextPipelineStageLabel && (parsed.pipelineStageId !== void 0 || !record.pipelineStage)) {
|
|
471
|
-
record.pipelineStage = nextPipelineStageLabel;
|
|
472
|
-
} else if (resolvedCurrentPipelineStageLabel && (parsed.pipelineStageId !== void 0 || !record.pipelineStage)) {
|
|
473
|
-
record.pipelineStage = resolvedCurrentPipelineStageLabel;
|
|
474
|
-
}
|
|
475
|
-
if (parsed.valueAmount !== void 0) record.valueAmount = toNumericString(parsed.valueAmount);
|
|
476
|
-
if (parsed.valueCurrency !== void 0) record.valueCurrency = parsed.valueCurrency ?? null;
|
|
477
|
-
if (parsed.probability !== void 0) record.probability = parsed.probability ?? null;
|
|
478
|
-
if (parsed.expectedCloseAt !== void 0) record.expectedCloseAt = parsed.expectedCloseAt ?? null;
|
|
479
|
-
if (parsed.ownerUserId !== void 0) record.ownerUserId = parsed.ownerUserId ?? null;
|
|
480
|
-
if (parsed.source !== void 0) record.source = parsed.source ?? null;
|
|
481
|
-
if (parsed.closureOutcome !== void 0) record.closureOutcome = parsed.closureOutcome ?? null;
|
|
482
|
-
if (parsed.lossReasonId !== void 0) record.lossReasonId = parsed.lossReasonId ?? null;
|
|
483
|
-
if (parsed.lossNotes !== void 0) record.lossNotes = parsed.lossNotes ?? null;
|
|
484
|
-
},
|
|
485
|
-
async () => {
|
|
486
|
-
await em.flush();
|
|
487
|
-
},
|
|
488
|
-
async () => {
|
|
489
|
-
const snapshot = nextStageSnapshot;
|
|
490
|
-
if (!snapshot) return;
|
|
491
|
-
const shouldRecord = parsed.pipelineStageId !== void 0 && parsed.pipelineStageId !== null && parsed.pipelineStageId !== previousPipelineStageId;
|
|
492
|
-
if (!shouldRecord) return;
|
|
493
|
-
await upsertDealStageTransition(em, {
|
|
494
|
-
deal: record,
|
|
495
|
-
pipelineId: snapshot.pipelineId,
|
|
496
|
-
stageId: snapshot.id,
|
|
497
|
-
stageLabel: nextPipelineStageLabel ?? snapshot.label,
|
|
498
|
-
stageOrder: snapshot.order,
|
|
499
|
-
transitionedByUserId: normalizedTransitionAuthorUserId
|
|
500
|
-
});
|
|
501
|
-
},
|
|
502
|
-
() => syncDealPeople(em, record, parsed.personIds),
|
|
503
|
-
() => syncDealCompanies(em, record, parsed.companyIds)
|
|
504
|
-
], { transaction: true });
|
|
505
|
-
const de = ctx.container.resolve("dataEngine");
|
|
506
|
-
await setCustomFieldsIfAny({
|
|
507
|
-
dataEngine: de,
|
|
441
|
+
await runCrudCommandWrite({
|
|
442
|
+
ctx,
|
|
443
|
+
em,
|
|
508
444
|
entityId: DEAL_ENTITY_ID,
|
|
509
|
-
recordId: record.id,
|
|
510
|
-
organizationId: record.organizationId,
|
|
511
|
-
tenantId: record.tenantId,
|
|
512
|
-
values: custom,
|
|
513
|
-
notify: false
|
|
514
|
-
});
|
|
515
|
-
await emitCrudSideEffects({
|
|
516
|
-
dataEngine: de,
|
|
517
445
|
action: "updated",
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
organizationId: record.organizationId,
|
|
522
|
-
tenantId: record.tenantId
|
|
523
|
-
},
|
|
446
|
+
scope: { tenantId: record.tenantId, organizationId: record.organizationId },
|
|
447
|
+
customFields: custom,
|
|
448
|
+
events: dealCrudEvents,
|
|
524
449
|
indexer: dealCrudIndexer,
|
|
525
|
-
|
|
450
|
+
sideEffect: () => ({
|
|
451
|
+
entity: record,
|
|
452
|
+
identifiers: {
|
|
453
|
+
id: record.id,
|
|
454
|
+
organizationId: record.organizationId,
|
|
455
|
+
tenantId: record.tenantId
|
|
456
|
+
}
|
|
457
|
+
}),
|
|
458
|
+
phases: [
|
|
459
|
+
async () => {
|
|
460
|
+
const pipelineAssignmentChanged = parsed.pipelineId !== void 0 || parsed.pipelineStageId !== void 0;
|
|
461
|
+
const requestedPipelineStageId = parsed.pipelineStageId !== void 0 ? parsed.pipelineStageId ?? null : record.pipelineStageId ?? null;
|
|
462
|
+
const requestedPipelineId = parsed.pipelineId !== void 0 ? parsed.pipelineId ?? null : record.pipelineId ?? null;
|
|
463
|
+
nextStageSnapshot = requestedPipelineStageId && (pipelineAssignmentChanged || !record.pipelineStage) ? await loadPipelineStageSnapshot(em, requestedPipelineStageId, record.tenantId, record.organizationId) : null;
|
|
464
|
+
if (pipelineAssignmentChanged) {
|
|
465
|
+
nextPipelineAssignment = resolvePipelineAssignment({
|
|
466
|
+
pipelineId: requestedPipelineId,
|
|
467
|
+
pipelineStageId: requestedPipelineStageId,
|
|
468
|
+
stageSnapshot: nextStageSnapshot
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
nextPipelineStageLabel = nextStageSnapshot ? (await ensureDictionaryEntry(em, {
|
|
472
|
+
tenantId: record.tenantId,
|
|
473
|
+
organizationId: record.organizationId,
|
|
474
|
+
kind: "pipeline_stage",
|
|
475
|
+
value: nextStageSnapshot.label
|
|
476
|
+
}))?.value ?? nextStageSnapshot.label : null;
|
|
477
|
+
resolvedCurrentPipelineStageLabel = !nextStageSnapshot && record.pipelineStageId && (parsed.pipelineStageId !== void 0 || !record.pipelineStage) ? await resolvePipelineStageValue(em, record.pipelineStageId, record.tenantId, record.organizationId) : null;
|
|
478
|
+
},
|
|
479
|
+
() => {
|
|
480
|
+
if (parsed.title !== void 0) record.title = parsed.title;
|
|
481
|
+
if (parsed.description !== void 0) record.description = parsed.description ?? null;
|
|
482
|
+
if (parsed.status !== void 0) record.status = parsed.status ?? record.status;
|
|
483
|
+
if (parsed.pipelineStage !== void 0) record.pipelineStage = parsed.pipelineStage ?? null;
|
|
484
|
+
if (parsed.pipelineId !== void 0 || parsed.pipelineStageId !== void 0 && nextStageSnapshot) {
|
|
485
|
+
record.pipelineId = nextPipelineAssignment.pipelineId;
|
|
486
|
+
}
|
|
487
|
+
if (parsed.pipelineStageId !== void 0) record.pipelineStageId = nextPipelineAssignment.pipelineStageId;
|
|
488
|
+
if (nextPipelineStageLabel && (parsed.pipelineStageId !== void 0 || !record.pipelineStage)) {
|
|
489
|
+
record.pipelineStage = nextPipelineStageLabel;
|
|
490
|
+
} else if (resolvedCurrentPipelineStageLabel && (parsed.pipelineStageId !== void 0 || !record.pipelineStage)) {
|
|
491
|
+
record.pipelineStage = resolvedCurrentPipelineStageLabel;
|
|
492
|
+
}
|
|
493
|
+
if (parsed.valueAmount !== void 0) record.valueAmount = toNumericString(parsed.valueAmount);
|
|
494
|
+
if (parsed.valueCurrency !== void 0) record.valueCurrency = parsed.valueCurrency ?? null;
|
|
495
|
+
if (parsed.probability !== void 0) record.probability = parsed.probability ?? null;
|
|
496
|
+
if (parsed.expectedCloseAt !== void 0) record.expectedCloseAt = parsed.expectedCloseAt ?? null;
|
|
497
|
+
if (parsed.ownerUserId !== void 0) record.ownerUserId = parsed.ownerUserId ?? null;
|
|
498
|
+
if (parsed.source !== void 0) record.source = parsed.source ?? null;
|
|
499
|
+
if (parsed.closureOutcome !== void 0) record.closureOutcome = parsed.closureOutcome ?? null;
|
|
500
|
+
if (parsed.lossReasonId !== void 0) record.lossReasonId = parsed.lossReasonId ?? null;
|
|
501
|
+
if (parsed.lossNotes !== void 0) record.lossNotes = parsed.lossNotes ?? null;
|
|
502
|
+
},
|
|
503
|
+
async () => {
|
|
504
|
+
await em.flush();
|
|
505
|
+
},
|
|
506
|
+
async () => {
|
|
507
|
+
const snapshot = nextStageSnapshot;
|
|
508
|
+
if (!snapshot) return;
|
|
509
|
+
const shouldRecord = parsed.pipelineStageId !== void 0 && parsed.pipelineStageId !== null && parsed.pipelineStageId !== previousPipelineStageId;
|
|
510
|
+
if (!shouldRecord) return;
|
|
511
|
+
await upsertDealStageTransition(em, {
|
|
512
|
+
deal: record,
|
|
513
|
+
pipelineId: snapshot.pipelineId,
|
|
514
|
+
stageId: snapshot.id,
|
|
515
|
+
stageLabel: nextPipelineStageLabel ?? snapshot.label,
|
|
516
|
+
stageOrder: snapshot.order,
|
|
517
|
+
transitionedByUserId: normalizedTransitionAuthorUserId
|
|
518
|
+
});
|
|
519
|
+
},
|
|
520
|
+
() => syncDealPeople(em, record, parsed.personIds),
|
|
521
|
+
() => syncDealCompanies(em, record, parsed.companyIds)
|
|
522
|
+
]
|
|
526
523
|
});
|
|
527
524
|
const newStatus = record.status;
|
|
528
525
|
const normalizedStatus = newStatus === "win" ? "won" : newStatus === "loose" ? "lost" : newStatus;
|