@erxes/api-shared 3.2.20-3 → 3.2.21-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/core-modules/dist/erxes-api-shared-core-modules.cjs.dev.js +512 -8
  2. package/core-modules/dist/erxes-api-shared-core-modules.cjs.prod.js +512 -8
  3. package/core-modules/dist/erxes-api-shared-core-modules.esm.js +507 -10
  4. package/dist/declarations/src/core-modules/automations/conditionals.d.ts +59 -0
  5. package/dist/declarations/src/core-modules/automations/conditionals.d.ts.map +1 -0
  6. package/dist/declarations/src/core-modules/automations/definitions/executions.d.ts +10 -0
  7. package/dist/declarations/src/core-modules/automations/definitions/executions.d.ts.map +1 -1
  8. package/dist/declarations/src/core-modules/automations/index.d.ts +1 -0
  9. package/dist/declarations/src/core-modules/automations/index.d.ts.map +1 -1
  10. package/dist/declarations/src/core-modules/automations/zodTypes.d.ts +40 -0
  11. package/dist/declarations/src/core-modules/automations/zodTypes.d.ts.map +1 -1
  12. package/dist/{start-plugin-a2f6eca9.esm.js → start-plugin-6a677721.esm.js} +5 -1
  13. package/dist/{start-plugin-b095acca.cjs.prod.js → start-plugin-8812291a.cjs.prod.js} +5 -0
  14. package/dist/{start-plugin-7dc096bc.cjs.dev.js → start-plugin-b6f1292f.cjs.dev.js} +5 -0
  15. package/package.json +1 -1
  16. package/src/core-modules/automations/conditionals.test.ts +302 -0
  17. package/src/core-modules/automations/conditionals.ts +403 -0
  18. package/src/core-modules/automations/definitions/executions.ts +3 -0
  19. package/src/core-modules/automations/index.ts +1 -0
  20. package/src/core-modules/automations/zodTypes.ts +1 -0
  21. package/utils/dist/erxes-api-shared-utils.cjs.dev.js +1 -1
  22. package/utils/dist/erxes-api-shared-utils.cjs.prod.js +1 -1
  23. package/utils/dist/erxes-api-shared-utils.esm.js +2 -2
@@ -0,0 +1,302 @@
1
+ import {
2
+ applyAutomationConditionals,
3
+ AUTOMATION_CONDITIONALS_KEY,
4
+ formatConditionalToken,
5
+ isAutomationConditionMatched,
6
+ parseConditionalToken,
7
+ TAutomationConditional,
8
+ validateAutomationConditional,
9
+ } from './conditionals';
10
+ import { IAutomationExecution } from './definitions/executions';
11
+ import { replaceOutputPlaceholders } from './outputResolvers';
12
+
13
+ jest.mock('./outputResolvers', () => ({
14
+ replaceOutputPlaceholders: jest.fn(),
15
+ }));
16
+
17
+ const mockedReplace = jest.mocked(replaceOutputPlaceholders);
18
+
19
+ // Stands in for the real resolver: `{{ name }}` reads from this run's values
20
+ const runValues: Record<string, unknown> = {};
21
+
22
+ const execution = { target: {} } as unknown as IAutomationExecution;
23
+
24
+ const assigneeConditional: TAutomationConditional = {
25
+ keys: ['assigneeId'],
26
+ label: 'Assignee',
27
+ rows: [
28
+ {
29
+ id: 'r1',
30
+ conditions: [
31
+ { kind: 'value', left: '{{ priority }}', operator: 'e', right: '4' },
32
+ {
33
+ kind: 'value',
34
+ left: '{{ secrecy }}',
35
+ operator: 'dne',
36
+ right: 'нууцлалтай',
37
+ },
38
+ ],
39
+ values: { assigneeId: '[[ user.bat ]]' },
40
+ },
41
+ {
42
+ id: 'r2',
43
+ conditions: [
44
+ { kind: 'value', left: '{{ category }}', operator: 'e', right: 'road' },
45
+ ],
46
+ values: { assigneeId: '[[ user.dorj ]]' },
47
+ },
48
+ ],
49
+ otherwise: { assigneeId: '[[ user.admin ]]' },
50
+ };
51
+
52
+ beforeEach(() => {
53
+ for (const key of Object.keys(runValues)) {
54
+ delete runValues[key];
55
+ }
56
+
57
+ mockedReplace.mockReset();
58
+ mockedReplace.mockImplementation(async ({ values }) =>
59
+ Object.fromEntries(
60
+ Object.entries(values).map(([key, value]) => {
61
+ const match = String(value).match(/^\{\{\s*(\w+)\s*\}\}$/);
62
+
63
+ return [key, match ? runValues[match[1]] ?? '' : value];
64
+ }),
65
+ ),
66
+ );
67
+ });
68
+
69
+ const noSegments = async () => false;
70
+
71
+ describe('conditional tokens', () => {
72
+ it('round-trips and tolerates spacing', () => {
73
+ expect(formatConditionalToken('k3x')).toBe('<< cond:k3x >>');
74
+ expect(parseConditionalToken('<< cond:k3x >>')).toBe('k3x');
75
+ expect(parseConditionalToken('<<cond:k3x>>')).toBe('k3x');
76
+ });
77
+
78
+ it('ignores text that only mentions a token', () => {
79
+ expect(parseConditionalToken('Hello << cond:k3x >>')).toBeNull();
80
+ expect(parseConditionalToken('{{ cond.k3x }}')).toBeNull();
81
+ expect(parseConditionalToken(42)).toBeNull();
82
+ });
83
+ });
84
+
85
+ describe('isAutomationConditionMatched', () => {
86
+ it('keeps split operator meanings', () => {
87
+ expect(isAutomationConditionMatched('4', 'e', '4')).toBe(true);
88
+ expect(isAutomationConditionMatched('4', 'dne', '4')).toBe(false);
89
+ expect(isAutomationConditionMatched('Хог овоорсон', 'c', 'хог')).toBe(true);
90
+ expect(isAutomationConditionMatched(10, 'numberigt', '5')).toBe(true);
91
+ expect(isAutomationConditionMatched('', 'ins', undefined)).toBe(true);
92
+ expect(isAutomationConditionMatched(true, 'it', undefined)).toBe(true);
93
+ expect(isAutomationConditionMatched('x', 'unknown', 'x')).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe('validateAutomationConditional', () => {
98
+ it('accepts a complete conditional', () => {
99
+ expect(
100
+ validateAutomationConditional(assigneeConditional, {
101
+ requiredKeys: ['assigneeId'],
102
+ }),
103
+ ).toEqual([]);
104
+ });
105
+
106
+ it('names every missing piece', () => {
107
+ const errors = validateAutomationConditional(
108
+ {
109
+ keys: ['assigneeId'],
110
+ rows: [
111
+ {
112
+ id: 'r1',
113
+ conditions: [
114
+ { kind: 'value', left: '', operator: 'e', right: '' },
115
+ { kind: 'value', left: '{{ x }}', operator: 'is' },
116
+ { kind: 'segment', segmentId: '' },
117
+ ],
118
+ values: {},
119
+ },
120
+ { id: 'r2', conditions: [], values: { assigneeId: 'a' } },
121
+ ],
122
+ otherwise: {},
123
+ },
124
+ { requiredKeys: ['assigneeId'] },
125
+ );
126
+
127
+ expect(errors).toEqual([
128
+ 'Row 1, condition 1: pick a value to check',
129
+ 'Row 1, condition 1: enter a value to compare with',
130
+ 'Row 1, condition 3: pick a segment',
131
+ 'Row 1: set a value',
132
+ 'Row 2: add at least one condition',
133
+ 'Otherwise: set a value',
134
+ ]);
135
+ });
136
+
137
+ it('lets an optional field fall back to nothing', () => {
138
+ expect(
139
+ validateAutomationConditional({ ...assigneeConditional, otherwise: {} }),
140
+ ).toEqual([]);
141
+ });
142
+ });
143
+
144
+ describe('applyAutomationConditionals', () => {
145
+ const config = {
146
+ channelId: 'c1',
147
+ assigneeId: formatConditionalToken('k3x'),
148
+ [AUTOMATION_CONDITIONALS_KEY]: { k3x: assigneeConditional },
149
+ };
150
+
151
+ it('uses the first row whose conditions all hold', async () => {
152
+ runValues.priority = '4';
153
+ runValues.secrecy = 'нээлттэй';
154
+ runValues.category = 'road';
155
+
156
+ const result = await applyAutomationConditionals({
157
+ subdomain: 'test',
158
+ execution,
159
+ config,
160
+ isInSegment: noSegments,
161
+ });
162
+
163
+ expect(result.config).toEqual({
164
+ channelId: 'c1',
165
+ assigneeId: '[[ user.bat ]]',
166
+ });
167
+ expect(result.matches).toEqual({ k3x: 'r1' });
168
+ expect(result.missing).toEqual([]);
169
+ });
170
+
171
+ it('falls to otherwise when no row holds', async () => {
172
+ runValues.priority = '2';
173
+
174
+ const result = await applyAutomationConditionals({
175
+ subdomain: 'test',
176
+ execution,
177
+ config,
178
+ isInSegment: noSegments,
179
+ });
180
+
181
+ expect(result.config.assigneeId).toBe('[[ user.admin ]]');
182
+ expect(result.matches).toEqual({ k3x: 'otherwise' });
183
+ });
184
+
185
+ it('patches every key the conditional owns, inside arrays too', async () => {
186
+ runValues.priority = '4';
187
+
188
+ const result = await applyAutomationConditionals({
189
+ subdomain: 'test',
190
+ execution,
191
+ config: {
192
+ rules: [
193
+ {
194
+ field: 'content',
195
+ value: formatConditionalToken('m9a'),
196
+ contentJson: { old: true },
197
+ },
198
+ ],
199
+ [AUTOMATION_CONDITIONALS_KEY]: {
200
+ m9a: {
201
+ keys: ['value', 'contentJson'],
202
+ rows: [
203
+ {
204
+ id: 'r1',
205
+ conditions: [
206
+ {
207
+ kind: 'value',
208
+ left: '{{ priority }}',
209
+ operator: 'e',
210
+ right: '4',
211
+ },
212
+ ],
213
+ values: { value: '<p>Urgent</p>', contentJson: { urgent: 1 } },
214
+ },
215
+ ],
216
+ otherwise: { value: '<p>Normal</p>', contentJson: {} },
217
+ },
218
+ },
219
+ },
220
+ isInSegment: noSegments,
221
+ });
222
+
223
+ expect(result.config).toEqual({
224
+ rules: [
225
+ {
226
+ field: 'content',
227
+ value: '<p>Urgent</p>',
228
+ contentJson: { urgent: 1 },
229
+ },
230
+ ],
231
+ });
232
+ });
233
+
234
+ it('decides a repeated condition once', async () => {
235
+ runValues.priority = '1';
236
+ const asked: string[] = [];
237
+
238
+ await applyAutomationConditionals({
239
+ subdomain: 'test',
240
+ execution,
241
+ config: {
242
+ a: formatConditionalToken('one'),
243
+ b: formatConditionalToken('two'),
244
+ [AUTOMATION_CONDITIONALS_KEY]: {
245
+ one: {
246
+ keys: ['a'],
247
+ rows: [
248
+ {
249
+ id: 'r1',
250
+ conditions: [{ kind: 'segment', segmentId: 'seg-1' }],
251
+ values: { a: 'x' },
252
+ },
253
+ ],
254
+ otherwise: { a: 'y' },
255
+ },
256
+ two: {
257
+ keys: ['b'],
258
+ rows: [
259
+ {
260
+ id: 'r1',
261
+ conditions: [{ kind: 'segment', segmentId: 'seg-1' }],
262
+ values: { b: 'x' },
263
+ },
264
+ ],
265
+ otherwise: { b: 'y' },
266
+ },
267
+ },
268
+ },
269
+ isInSegment: async (segmentId) => {
270
+ asked.push(segmentId);
271
+ return true;
272
+ },
273
+ });
274
+
275
+ expect(asked).toEqual(['seg-1']);
276
+ });
277
+
278
+ it('reports a token whose rules are gone instead of passing it on', async () => {
279
+ const result = await applyAutomationConditionals({
280
+ subdomain: 'test',
281
+ execution,
282
+ config: { assigneeId: formatConditionalToken('gone') },
283
+ isInSegment: noSegments,
284
+ });
285
+
286
+ expect(result.missing).toEqual(['gone']);
287
+ expect(result.config.assigneeId).toBe('');
288
+ });
289
+
290
+ it('leaves a config without conditionals untouched', async () => {
291
+ const result = await applyAutomationConditionals({
292
+ subdomain: 'test',
293
+ execution,
294
+ config: { name: '{{ trigger.title }}', tags: ['a'] },
295
+ isInSegment: noSegments,
296
+ });
297
+
298
+ expect(result.config).toEqual({ name: '{{ trigger.title }}', tags: ['a'] });
299
+ expect(result.matches).toEqual({});
300
+ expect(mockedReplace).not.toHaveBeenCalled();
301
+ });
302
+ });
@@ -0,0 +1,403 @@
1
+ import { IAutomationExecution } from './definitions/executions';
2
+ import { replaceOutputPlaceholders } from './outputResolvers';
3
+
4
+ /**
5
+ * A field whose value depends on the run holds a token instead of a value;
6
+ * the rules behind it live in the action config under this key. The engine
7
+ * swaps tokens for the first matching row before the action ever sees its
8
+ * config, so no action has to know conditionals exist.
9
+ */
10
+ export const AUTOMATION_CONDITIONALS_KEY = '_conditionals';
11
+
12
+ const CONDITIONAL_TOKEN_REGEX = /^<<\s*cond:([A-Za-z0-9_-]+)\s*>>$/;
13
+
14
+ export const formatConditionalToken = (id: string) => `<< cond:${id} >>`;
15
+
16
+ // The whole value must be the token, so text that merely mentions one is left alone
17
+ export const parseConditionalToken = (value: unknown): string | null => {
18
+ if (typeof value !== 'string') {
19
+ return null;
20
+ }
21
+
22
+ const match = value.match(CONDITIONAL_TOKEN_REGEX);
23
+
24
+ return match ? match[1] : null;
25
+ };
26
+
27
+ export type TAutomationValueCondition = {
28
+ kind: 'value';
29
+ left: string;
30
+ operator: string;
31
+ right?: string;
32
+ meta?: Record<string, unknown>;
33
+ };
34
+
35
+ export type TAutomationSegmentCondition = {
36
+ kind: 'segment';
37
+ segmentId: string;
38
+ };
39
+
40
+ export type TAutomationCondition =
41
+ | TAutomationValueCondition
42
+ | TAutomationSegmentCondition;
43
+
44
+ export type TAutomationConditionalRow = {
45
+ id: string;
46
+ conjunction?: 'and' | 'or';
47
+ conditions: TAutomationCondition[];
48
+ values: Record<string, unknown>;
49
+ };
50
+
51
+ export type TAutomationConditional = {
52
+ keys: string[];
53
+ label?: string;
54
+ rows: TAutomationConditionalRow[];
55
+ otherwise: Record<string, unknown>;
56
+ };
57
+
58
+ export type TAutomationConditionals = Record<string, TAutomationConditional>;
59
+
60
+ // Operators that test presence or truth and take no right-hand value
61
+ export const AUTOMATION_UNARY_OPERATORS = [
62
+ 'is',
63
+ 'ins',
64
+ 'dateis',
65
+ 'dateins',
66
+ 'it',
67
+ 'if',
68
+ ];
69
+
70
+ const isEmptyValue = (value: unknown) =>
71
+ value === undefined ||
72
+ value === null ||
73
+ value === '' ||
74
+ (Array.isArray(value) && !value.length);
75
+
76
+ const normalizeDate = (value: unknown) => {
77
+ const date =
78
+ value instanceof Date ? value : new Date(value as string | number);
79
+
80
+ return Number.isNaN(date.getTime()) ? null : date.getTime();
81
+ };
82
+
83
+ const compareDates = (
84
+ actual: unknown,
85
+ expected: unknown,
86
+ compare: (a: number, b: number) => boolean,
87
+ ) => {
88
+ const actualDate = normalizeDate(actual);
89
+ const expectedDate = normalizeDate(expected);
90
+
91
+ return actualDate !== null && expectedDate !== null
92
+ ? compare(actualDate, expectedDate)
93
+ : false;
94
+ };
95
+
96
+ /** Split and conditional values share one meaning per operator. */
97
+ export const isAutomationConditionMatched = (
98
+ actualValue: unknown,
99
+ operator: string | undefined,
100
+ expectedValue: unknown,
101
+ ) => {
102
+ switch (operator) {
103
+ case 'e':
104
+ return actualValue === expectedValue;
105
+
106
+ case 'numbere':
107
+ return Number(actualValue) === Number(expectedValue);
108
+
109
+ case 'dne':
110
+ return actualValue !== expectedValue;
111
+
112
+ case 'numberdne':
113
+ return Number(actualValue) !== Number(expectedValue);
114
+
115
+ case 'c':
116
+ return String(actualValue || '')
117
+ .toLowerCase()
118
+ .includes(String(expectedValue || '').toLowerCase());
119
+
120
+ case 'dnc':
121
+ return !String(actualValue || '')
122
+ .toLowerCase()
123
+ .includes(String(expectedValue || '').toLowerCase());
124
+
125
+ case 'it':
126
+ return actualValue === true;
127
+
128
+ case 'if':
129
+ return actualValue === false;
130
+
131
+ case 'is':
132
+ case 'dateis':
133
+ return !isEmptyValue(actualValue);
134
+
135
+ case 'ins':
136
+ case 'dateins':
137
+ return isEmptyValue(actualValue);
138
+
139
+ case 'numberigt':
140
+ return Number(actualValue) >= Number(expectedValue);
141
+
142
+ case 'numberilt':
143
+ return Number(actualValue) <= Number(expectedValue);
144
+
145
+ case 'dateigt':
146
+ return compareDates(actualValue, expectedValue, (a, b) => a >= b);
147
+
148
+ case 'dateilt':
149
+ return compareDates(actualValue, expectedValue, (a, b) => a <= b);
150
+
151
+ default:
152
+ return false;
153
+ }
154
+ };
155
+
156
+ /** Problems that keep a conditional from running; empty when it can. */
157
+ export const validateAutomationConditional = (
158
+ conditional: TAutomationConditional,
159
+ { requiredKeys = [] }: { requiredKeys?: string[] } = {},
160
+ ) => {
161
+ const errors: string[] = [];
162
+
163
+ if (!conditional.rows.length) {
164
+ errors.push('Add at least one condition row');
165
+ }
166
+
167
+ conditional.rows.forEach((row, rowIndex) => {
168
+ const at = `Row ${rowIndex + 1}`;
169
+
170
+ if (!row.conditions.length) {
171
+ errors.push(`${at}: add at least one condition`);
172
+ }
173
+
174
+ row.conditions.forEach((condition, conditionIndex) => {
175
+ const where = `${at}, condition ${conditionIndex + 1}`;
176
+
177
+ if (condition.kind === 'segment') {
178
+ if (!condition.segmentId) {
179
+ errors.push(`${where}: pick a segment`);
180
+ }
181
+
182
+ return;
183
+ }
184
+
185
+ if (!condition.left?.trim()) {
186
+ errors.push(`${where}: pick a value to check`);
187
+ }
188
+
189
+ if (!condition.operator) {
190
+ errors.push(`${where}: pick an operator`);
191
+ } else if (
192
+ !AUTOMATION_UNARY_OPERATORS.includes(condition.operator) &&
193
+ isEmptyValue(condition.right)
194
+ ) {
195
+ errors.push(`${where}: enter a value to compare with`);
196
+ }
197
+ });
198
+
199
+ for (const key of conditional.keys) {
200
+ if (isEmptyValue(row.values?.[key])) {
201
+ errors.push(`${at}: set a value`);
202
+ break;
203
+ }
204
+ }
205
+ });
206
+
207
+ for (const key of requiredKeys) {
208
+ if (isEmptyValue(conditional.otherwise?.[key])) {
209
+ errors.push('Otherwise: set a value');
210
+ break;
211
+ }
212
+ }
213
+
214
+ return errors;
215
+ };
216
+
217
+ const conditionCacheKey = (condition: TAutomationCondition) =>
218
+ condition.kind === 'segment'
219
+ ? `segment|${condition.segmentId}`
220
+ : `value|${condition.left}|${condition.operator}|${condition.right ?? ''}`;
221
+
222
+ type TConditionEvaluator = (
223
+ condition: TAutomationCondition,
224
+ ) => Promise<boolean>;
225
+
226
+ // One answer per distinct condition for the whole action, however many
227
+ // fields repeat it
228
+ const createConditionEvaluator = ({
229
+ subdomain,
230
+ execution,
231
+ isInSegment,
232
+ }: {
233
+ subdomain: string;
234
+ execution: IAutomationExecution;
235
+ isInSegment: (segmentId: string) => Promise<boolean>;
236
+ }): TConditionEvaluator => {
237
+ const answers = new Map<string, Promise<boolean>>();
238
+
239
+ const evaluate = async (condition: TAutomationCondition) => {
240
+ if (condition.kind === 'segment') {
241
+ return isInSegment(condition.segmentId);
242
+ }
243
+
244
+ const resolved = await replaceOutputPlaceholders({
245
+ subdomain,
246
+ execution,
247
+ values: { left: condition.left, right: condition.right ?? '' },
248
+ defaultValue: '',
249
+ keepUnresolvedPlaceholders: false,
250
+ });
251
+
252
+ return isAutomationConditionMatched(
253
+ resolved.left,
254
+ condition.operator,
255
+ resolved.right,
256
+ );
257
+ };
258
+
259
+ return (condition) => {
260
+ const key = conditionCacheKey(condition);
261
+
262
+ if (!answers.has(key)) {
263
+ answers.set(key, evaluate(condition));
264
+ }
265
+
266
+ return answers.get(key) as Promise<boolean>;
267
+ };
268
+ };
269
+
270
+ const isRowMatched = async (
271
+ row: TAutomationConditionalRow,
272
+ evaluate: TConditionEvaluator,
273
+ ) => {
274
+ if (!row.conditions.length) {
275
+ return false;
276
+ }
277
+
278
+ if (row.conjunction === 'or') {
279
+ for (const condition of row.conditions) {
280
+ if (await evaluate(condition)) {
281
+ return true;
282
+ }
283
+ }
284
+
285
+ return false;
286
+ }
287
+
288
+ for (const condition of row.conditions) {
289
+ if (!(await evaluate(condition))) {
290
+ return false;
291
+ }
292
+ }
293
+
294
+ return true;
295
+ };
296
+
297
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
298
+ typeof value === 'object' && value !== null && !Array.isArray(value);
299
+
300
+ /**
301
+ * Returns the config the action should run with: every token replaced by its
302
+ * first matching row (or otherwise), the conditionals key removed, and which
303
+ * row each conditional used. Tokens without a conditional are reported, never
304
+ * passed through as text.
305
+ */
306
+ export const applyAutomationConditionals = async ({
307
+ subdomain,
308
+ execution,
309
+ config,
310
+ isInSegment,
311
+ }: {
312
+ subdomain: string;
313
+ execution: IAutomationExecution;
314
+ config: Record<string, unknown> | undefined;
315
+ isInSegment: (segmentId: string) => Promise<boolean>;
316
+ }) => {
317
+ const { [AUTOMATION_CONDITIONALS_KEY]: rawConditionals, ...rest } =
318
+ config || {};
319
+ const conditionals = (
320
+ isRecord(rawConditionals) ? rawConditionals : {}
321
+ ) as TAutomationConditionals;
322
+
323
+ const evaluate = createConditionEvaluator({
324
+ subdomain,
325
+ execution,
326
+ isInSegment,
327
+ });
328
+ const matches: Record<string, string> = {};
329
+ const missing: string[] = [];
330
+ const chosenById = new Map<string, Record<string, unknown>>();
331
+
332
+ const choose = async (id: string, conditional: TAutomationConditional) => {
333
+ if (!chosenById.has(id)) {
334
+ let chosen: { rowId: string; values: Record<string, unknown> } = {
335
+ rowId: 'otherwise',
336
+ values: conditional.otherwise || {},
337
+ };
338
+
339
+ for (const row of conditional.rows || []) {
340
+ if (await isRowMatched(row, evaluate)) {
341
+ chosen = { rowId: row.id, values: row.values || {} };
342
+ break;
343
+ }
344
+ }
345
+
346
+ matches[id] = chosen.rowId;
347
+ chosenById.set(id, {
348
+ ...(conditional.otherwise || {}),
349
+ ...chosen.values,
350
+ });
351
+ }
352
+
353
+ return chosenById.get(id) as Record<string, unknown>;
354
+ };
355
+
356
+ const walk = async (node: unknown): Promise<unknown> => {
357
+ if (Array.isArray(node)) {
358
+ return Promise.all(node.map(walk));
359
+ }
360
+
361
+ if (!isRecord(node)) {
362
+ return node;
363
+ }
364
+
365
+ const next: Record<string, unknown> = {};
366
+ const patches: Record<string, unknown>[] = [];
367
+
368
+ for (const [key, value] of Object.entries(node)) {
369
+ const id = parseConditionalToken(value);
370
+
371
+ if (!id) {
372
+ next[key] = await walk(value);
373
+ continue;
374
+ }
375
+
376
+ const conditional = conditionals[id];
377
+
378
+ if (!conditional) {
379
+ missing.push(id);
380
+ next[key] = '';
381
+ continue;
382
+ }
383
+
384
+ const chosen = await choose(id, conditional);
385
+ const keys = conditional.keys?.length ? conditional.keys : [key];
386
+
387
+ patches.push(
388
+ Object.fromEntries(
389
+ keys.map((patchKey) => [patchKey, chosen[patchKey]]),
390
+ ),
391
+ );
392
+ }
393
+
394
+ // Siblings a conditional owns are overwritten after the plain values
395
+ return Object.assign(next, ...patches);
396
+ };
397
+
398
+ return {
399
+ config: (await walk(rest)) as Record<string, unknown>,
400
+ matches,
401
+ missing,
402
+ };
403
+ };
@@ -27,6 +27,8 @@ export interface IAutomationExecAction {
27
27
  attempt?: number;
28
28
  // Set on workflow node actions: links to the child execution for drill-in
29
29
  childExecutionId?: string;
30
+ // Which row each conditional value used, keyed by conditional id
31
+ conditionalMatches?: Record<string, string>;
30
32
  // Deferred actions only: matches the completion callback to this attempt so
31
33
  // a stale or retried job can never overwrite a newer one.
32
34
  jobId?: string;
@@ -120,6 +122,7 @@ const execActionSchema = new Schema({
120
122
  nextActionId: { type: String },
121
123
  result: { type: Object },
122
124
  childExecutionId: { type: String },
125
+ conditionalMatches: { type: Object },
123
126
  errorCode: { type: String },
124
127
  skipReason: { type: String },
125
128
  attempt: { type: Number },