@erxes/api-shared 3.2.21-1 → 3.2.22-1
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/core-modules/dist/erxes-api-shared-core-modules.cjs.dev.js +512 -8
- package/core-modules/dist/erxes-api-shared-core-modules.cjs.prod.js +512 -8
- package/core-modules/dist/erxes-api-shared-core-modules.esm.js +507 -10
- package/dist/declarations/src/core-modules/automations/conditionals.d.ts +59 -0
- package/dist/declarations/src/core-modules/automations/conditionals.d.ts.map +1 -0
- package/dist/declarations/src/core-modules/automations/definitions/executions.d.ts +10 -0
- package/dist/declarations/src/core-modules/automations/definitions/executions.d.ts.map +1 -1
- package/dist/declarations/src/core-modules/automations/index.d.ts +1 -0
- package/dist/declarations/src/core-modules/automations/index.d.ts.map +1 -1
- package/dist/declarations/src/core-modules/automations/zodTypes.d.ts +40 -0
- package/dist/declarations/src/core-modules/automations/zodTypes.d.ts.map +1 -1
- package/dist/declarations/src/core-modules/common/eventHandlers/createEventHandlers.d.ts.map +1 -1
- package/dist/declarations/src/core-modules/segments/joinChanges.d.ts +5 -1
- package/dist/declarations/src/core-modules/segments/joinChanges.d.ts.map +1 -1
- package/dist/{start-plugin-a2f6eca9.esm.js → start-plugin-2b3e89f5.esm.js} +73 -59
- package/dist/{start-plugin-b095acca.cjs.prod.js → start-plugin-46baacee.cjs.prod.js} +73 -58
- package/dist/{start-plugin-7dc096bc.cjs.dev.js → start-plugin-6687b329.cjs.dev.js} +73 -58
- package/package.json +1 -1
- package/src/core-modules/automations/conditionals.test.ts +302 -0
- package/src/core-modules/automations/conditionals.ts +403 -0
- package/src/core-modules/automations/definitions/executions.ts +3 -0
- package/src/core-modules/automations/index.ts +1 -0
- package/src/core-modules/automations/zodTypes.ts +1 -0
- package/src/core-modules/common/eventHandlers/createEventHandlers.ts +20 -2
- package/src/core-modules/segments/joinChanges.test.ts +62 -0
- package/src/core-modules/segments/joinChanges.ts +26 -11
- package/utils/dist/erxes-api-shared-utils.cjs.dev.js +1 -1
- package/utils/dist/erxes-api-shared-utils.cjs.prod.js +1 -1
- 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 },
|