@manablox/workflows 0.2.0 → 0.4.0
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/dist/index.d.ts +302 -0
- package/dist/index.js +1255 -0
- package/package.json +16 -9
- package/src/conditions.ts +0 -88
- package/src/cron.ts +0 -181
- package/src/engine.ts +0 -499
- package/src/index.ts +0 -9
- package/src/mail.ts +0 -38
- package/src/push.ts +0 -61
- package/src/service.ts +0 -167
- package/src/steps.ts +0 -256
- package/src/template.ts +0 -65
- package/src/validate.ts +0 -267
- package/test/conditions.test.ts +0 -80
- package/test/cron.test.ts +0 -79
- package/test/engine.test.ts +0 -555
- package/test/template.test.ts +0 -41
- package/test/validate.test.ts +0 -129
- package/tsconfig.json +0 -1
- package/vitest.config.ts +0 -9
package/dist/index.js
ADDED
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
import { ManabloxError, WORKFLOW_CONDITION_OPERATORS, WORKFLOW_CONDITION_OPERATOR_LABELS, WORKFLOW_EVENTS, WORKFLOW_EVENT_LABELS, WORKFLOW_STEP_LABELS, WORKFLOW_STEP_TYPES, diffRecords, isEmailAddress, runAsActor, snapshotChanges } from "@manablox/core";
|
|
2
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
3
|
+
import nodemailer from "nodemailer";
|
|
4
|
+
import webpush from "web-push";
|
|
5
|
+
import { requireInSpace } from "@manablox/services";
|
|
6
|
+
//#region src/template.ts
|
|
7
|
+
/**
|
|
8
|
+
* The placeholder syntax templates use: `{{ content.title }}`, `{{ content.fields.body }}`,
|
|
9
|
+
* `{{ actor.email }}`. A path resolves into the run context; an object or array comes out
|
|
10
|
+
* as JSON, anything missing as an empty string. Deliberately no logic — a workflow that
|
|
11
|
+
* needs a branch has a condition step for it.
|
|
12
|
+
*/
|
|
13
|
+
const PLACEHOLDER = /\{\{\s*([a-zA-Z0-9_.[\]-]+)\s*\}\}/g;
|
|
14
|
+
function resolvePath(root, path) {
|
|
15
|
+
let current = root;
|
|
16
|
+
for (const segment of path.replace(/\[(\d+)\]/g, ".$1").split(".")) {
|
|
17
|
+
if (segment === "") continue;
|
|
18
|
+
if (current === null || current === void 0) return void 0;
|
|
19
|
+
if (typeof current !== "object") return void 0;
|
|
20
|
+
current = current[segment];
|
|
21
|
+
}
|
|
22
|
+
return current;
|
|
23
|
+
}
|
|
24
|
+
function stringify(value) {
|
|
25
|
+
if (value === null || value === void 0) return "";
|
|
26
|
+
if (typeof value === "string") return value;
|
|
27
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
28
|
+
if (value instanceof Date) return value.toISOString();
|
|
29
|
+
return JSON.stringify(value);
|
|
30
|
+
}
|
|
31
|
+
function render(template, context) {
|
|
32
|
+
return template.replace(PLACEHOLDER, (_match, path) => stringify(resolvePath(context, path)));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Renders inside a JSON document: a placeholder that stands alone in a string is
|
|
36
|
+
* replaced by the value itself (`"count": "{{ documents.length }}"` becomes a number,
|
|
37
|
+
* `"doc": "{{ content }}"` an object); one embedded in text renders as text.
|
|
38
|
+
*/
|
|
39
|
+
function renderJson(template, context) {
|
|
40
|
+
const parsed = JSON.parse(template);
|
|
41
|
+
const walk = (value) => {
|
|
42
|
+
if (typeof value === "string") {
|
|
43
|
+
const alone = /^\{\{\s*([a-zA-Z0-9_.[\]-]+)\s*\}\}$/.exec(value);
|
|
44
|
+
if (alone) {
|
|
45
|
+
const resolved = resolvePath(context, alone[1]);
|
|
46
|
+
return resolved === void 0 ? null : resolved;
|
|
47
|
+
}
|
|
48
|
+
return render(value, context);
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(value)) return value.map(walk);
|
|
51
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, walk(entry)]));
|
|
52
|
+
return value;
|
|
53
|
+
};
|
|
54
|
+
return JSON.stringify(walk(parsed));
|
|
55
|
+
}
|
|
56
|
+
/** Every placeholder a template names, for the editor's hints and for validation. */
|
|
57
|
+
function placeholders(template) {
|
|
58
|
+
return [...template.matchAll(PLACEHOLDER)].map((match) => match[1]);
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/conditions.ts
|
|
62
|
+
const isEmpty = (value) => value === null || value === void 0 || value === "" || Array.isArray(value) && value.length === 0 || typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0;
|
|
63
|
+
const asNumber = (value) => {
|
|
64
|
+
if (typeof value === "number") return value;
|
|
65
|
+
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) return Number(value);
|
|
66
|
+
return null;
|
|
67
|
+
};
|
|
68
|
+
/** Loose equality across the types a field can hold: `"true"` matches `true`, `"3"` matches `3`. */
|
|
69
|
+
const same = (actual, expected) => {
|
|
70
|
+
if (typeof actual === "string") return actual === expected;
|
|
71
|
+
if (typeof actual === "number" || typeof actual === "boolean") return String(actual) === expected;
|
|
72
|
+
if (actual === null || actual === void 0) return expected === "" || expected === "null";
|
|
73
|
+
return stringify(actual) === expected;
|
|
74
|
+
};
|
|
75
|
+
/** `changed` compares the path under `content` with the same path under `previous`. */
|
|
76
|
+
function previousValue(context, field) {
|
|
77
|
+
if (!field.startsWith("content")) return void 0;
|
|
78
|
+
const rest = field.slice(7);
|
|
79
|
+
return resolvePath(context.previous, rest.replace(/^\./, ""));
|
|
80
|
+
}
|
|
81
|
+
function evaluateRule(rule, context) {
|
|
82
|
+
const actual = resolvePath(context, rule.field);
|
|
83
|
+
const expected = render(rule.value ?? "", context);
|
|
84
|
+
switch (rule.operator) {
|
|
85
|
+
case "equals": return same(actual, expected);
|
|
86
|
+
case "notEquals": return !same(actual, expected);
|
|
87
|
+
case "contains": return Array.isArray(actual) ? actual.some((entry) => same(entry, expected)) : stringify(actual).toLowerCase().includes(expected.toLowerCase());
|
|
88
|
+
case "notContains": return Array.isArray(actual) ? !actual.some((entry) => same(entry, expected)) : !stringify(actual).toLowerCase().includes(expected.toLowerCase());
|
|
89
|
+
case "startsWith": return stringify(actual).toLowerCase().startsWith(expected.toLowerCase());
|
|
90
|
+
case "isEmpty": return isEmpty(actual);
|
|
91
|
+
case "isNotEmpty": return !isEmpty(actual);
|
|
92
|
+
case "greaterThan": {
|
|
93
|
+
const a = asNumber(actual);
|
|
94
|
+
const b = asNumber(expected);
|
|
95
|
+
return a !== null && b !== null ? a > b : stringify(actual) > expected;
|
|
96
|
+
}
|
|
97
|
+
case "lessThan": {
|
|
98
|
+
const a = asNumber(actual);
|
|
99
|
+
const b = asNumber(expected);
|
|
100
|
+
return a !== null && b !== null ? a < b : stringify(actual) < expected;
|
|
101
|
+
}
|
|
102
|
+
case "changed":
|
|
103
|
+
if (!context.previous) return true;
|
|
104
|
+
return stringify(actual) !== stringify(previousValue(context, rule.field));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Whether the run may continue past this step. */
|
|
108
|
+
function evaluateCondition(step, context) {
|
|
109
|
+
if (step.rules.length === 0) return true;
|
|
110
|
+
const results = step.rules.map((rule) => evaluateRule(rule, context));
|
|
111
|
+
return step.match === "any" ? results.some(Boolean) : results.every(Boolean);
|
|
112
|
+
}
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region src/cron.ts
|
|
115
|
+
const MONTHS = [
|
|
116
|
+
"jan",
|
|
117
|
+
"feb",
|
|
118
|
+
"mar",
|
|
119
|
+
"apr",
|
|
120
|
+
"may",
|
|
121
|
+
"jun",
|
|
122
|
+
"jul",
|
|
123
|
+
"aug",
|
|
124
|
+
"sep",
|
|
125
|
+
"oct",
|
|
126
|
+
"nov",
|
|
127
|
+
"dec"
|
|
128
|
+
];
|
|
129
|
+
const DAYS = [
|
|
130
|
+
"sun",
|
|
131
|
+
"mon",
|
|
132
|
+
"tue",
|
|
133
|
+
"wed",
|
|
134
|
+
"thu",
|
|
135
|
+
"fri",
|
|
136
|
+
"sat"
|
|
137
|
+
];
|
|
138
|
+
const FIELDS = [
|
|
139
|
+
{
|
|
140
|
+
min: 0,
|
|
141
|
+
max: 59
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
min: 0,
|
|
145
|
+
max: 23
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
min: 1,
|
|
149
|
+
max: 31
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
min: 1,
|
|
153
|
+
max: 12,
|
|
154
|
+
names: MONTHS
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
min: 0,
|
|
158
|
+
max: 6,
|
|
159
|
+
names: DAYS,
|
|
160
|
+
alias: { 7: 0 }
|
|
161
|
+
}
|
|
162
|
+
];
|
|
163
|
+
function parseValue(raw, field) {
|
|
164
|
+
const lower = raw.toLowerCase();
|
|
165
|
+
if (field.names) {
|
|
166
|
+
const index = field.names.indexOf(lower);
|
|
167
|
+
if (index !== -1) return field.min + index;
|
|
168
|
+
}
|
|
169
|
+
if (!/^\d+$/.test(raw)) return null;
|
|
170
|
+
let value = Number(raw);
|
|
171
|
+
if (field.alias && value in field.alias) value = field.alias[value];
|
|
172
|
+
if (value < field.min || value > field.max) return null;
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
/** One comma-separated part: `*`, `n`, `a-b`, `*/s`, `a-b/s`, `a/s`. */
|
|
176
|
+
function parsePart(part, field, into) {
|
|
177
|
+
const [rangePart, stepPart] = part.split("/");
|
|
178
|
+
if (rangePart === void 0 || stepPart === "") return false;
|
|
179
|
+
let step = 1;
|
|
180
|
+
if (stepPart !== void 0) {
|
|
181
|
+
if (!/^\d+$/.test(stepPart)) return false;
|
|
182
|
+
step = Number(stepPart);
|
|
183
|
+
if (step < 1) return false;
|
|
184
|
+
}
|
|
185
|
+
let from;
|
|
186
|
+
let to;
|
|
187
|
+
if (rangePart === "*") {
|
|
188
|
+
from = field.min;
|
|
189
|
+
to = field.max;
|
|
190
|
+
} else if (rangePart.includes("-")) {
|
|
191
|
+
const [a, b] = rangePart.split("-");
|
|
192
|
+
const start = a === void 0 ? null : parseValue(a, field);
|
|
193
|
+
const end = b === void 0 ? null : parseValue(b, field);
|
|
194
|
+
if (start === null || end === null || end < start) return false;
|
|
195
|
+
from = start;
|
|
196
|
+
to = end;
|
|
197
|
+
} else {
|
|
198
|
+
const value = parseValue(rangePart, field);
|
|
199
|
+
if (value === null) return false;
|
|
200
|
+
from = value;
|
|
201
|
+
to = stepPart !== void 0 ? field.max : value;
|
|
202
|
+
}
|
|
203
|
+
for (let value = from; value <= to; value += step) into.add(value);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
function parseCron(expression) {
|
|
207
|
+
const fields = expression.trim().split(/\s+/);
|
|
208
|
+
if (fields.length !== 5) return null;
|
|
209
|
+
const sets = [];
|
|
210
|
+
for (const [index, raw] of fields.entries()) {
|
|
211
|
+
const field = FIELDS[index];
|
|
212
|
+
const set = /* @__PURE__ */ new Set();
|
|
213
|
+
for (const part of raw.split(",")) if (!part || !parsePart(part, field, set)) return null;
|
|
214
|
+
sets.push(set);
|
|
215
|
+
}
|
|
216
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = sets;
|
|
217
|
+
return {
|
|
218
|
+
minute,
|
|
219
|
+
hour,
|
|
220
|
+
dayOfMonth,
|
|
221
|
+
month,
|
|
222
|
+
dayOfWeek,
|
|
223
|
+
anyDayOfMonth: fields[2] === "*",
|
|
224
|
+
anyDayOfWeek: fields[4] === "*"
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function isValidCron(expression) {
|
|
228
|
+
return parseCron(expression) !== null;
|
|
229
|
+
}
|
|
230
|
+
function isValidTimezone(timezone) {
|
|
231
|
+
try {
|
|
232
|
+
new Intl.DateTimeFormat("en-US", { timeZone: timezone });
|
|
233
|
+
return true;
|
|
234
|
+
} catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** The wall-clock reading of an instant in a timezone. */
|
|
239
|
+
function wallClock(date, timezone) {
|
|
240
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
241
|
+
timeZone: timezone,
|
|
242
|
+
hourCycle: "h23",
|
|
243
|
+
minute: "numeric",
|
|
244
|
+
hour: "numeric",
|
|
245
|
+
day: "numeric",
|
|
246
|
+
month: "numeric",
|
|
247
|
+
weekday: "short"
|
|
248
|
+
}).formatToParts(date);
|
|
249
|
+
const read = (type) => parts.find((part) => part.type === type)?.value ?? "";
|
|
250
|
+
return {
|
|
251
|
+
minute: Number(read("minute")),
|
|
252
|
+
hour: Number(read("hour")) % 24,
|
|
253
|
+
dayOfMonth: Number(read("day")),
|
|
254
|
+
month: Number(read("month")),
|
|
255
|
+
dayOfWeek: DAYS.indexOf(read("weekday").toLowerCase().slice(0, 3))
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Whether the minute containing `date` matches. As in Vixie cron, when both day fields
|
|
260
|
+
* are restricted a match on either is enough.
|
|
261
|
+
*/
|
|
262
|
+
function cronMatches(spec, date, timezone) {
|
|
263
|
+
const clock = wallClock(date, timezone);
|
|
264
|
+
if (!spec.minute.has(clock.minute)) return false;
|
|
265
|
+
if (!spec.hour.has(clock.hour)) return false;
|
|
266
|
+
if (!spec.month.has(clock.month)) return false;
|
|
267
|
+
const domMatch = spec.dayOfMonth.has(clock.dayOfMonth);
|
|
268
|
+
const dowMatch = spec.dayOfWeek.has(clock.dayOfWeek);
|
|
269
|
+
if (spec.anyDayOfMonth && spec.anyDayOfWeek) return true;
|
|
270
|
+
if (spec.anyDayOfMonth) return dowMatch;
|
|
271
|
+
if (spec.anyDayOfWeek) return domMatch;
|
|
272
|
+
return domMatch || dowMatch;
|
|
273
|
+
}
|
|
274
|
+
/** The start of the minute an instant falls in — the unit the scheduler claims. */
|
|
275
|
+
function floorToMinute(date) {
|
|
276
|
+
return /* @__PURE__ */ new Date(Math.floor(date.getTime() / 6e4) * 6e4);
|
|
277
|
+
}
|
|
278
|
+
//#endregion
|
|
279
|
+
//#region src/steps.ts
|
|
280
|
+
/** Runs one step against a context. Throws when the step fails; the runner logs it. */
|
|
281
|
+
async function executeStep(step, context, env) {
|
|
282
|
+
switch (step.type) {
|
|
283
|
+
case "email": return sendEmail(step, context, env);
|
|
284
|
+
case "http": return callHttp(step, context, env);
|
|
285
|
+
case "push": return sendPush(step, context, env);
|
|
286
|
+
case "condition": return evaluateCondition(step, context) ? {
|
|
287
|
+
kind: "ok",
|
|
288
|
+
message: "Conditions met",
|
|
289
|
+
detail: null
|
|
290
|
+
} : {
|
|
291
|
+
kind: "stop",
|
|
292
|
+
message: "Conditions not met"
|
|
293
|
+
};
|
|
294
|
+
case "delay": return {
|
|
295
|
+
kind: "wait",
|
|
296
|
+
minutes: step.minutes
|
|
297
|
+
};
|
|
298
|
+
case "branch": return evaluateCondition(step, context) ? {
|
|
299
|
+
kind: "ok",
|
|
300
|
+
message: "Rules hold",
|
|
301
|
+
detail: { branch: "then" }
|
|
302
|
+
} : {
|
|
303
|
+
kind: "ok",
|
|
304
|
+
message: "Rules do not hold",
|
|
305
|
+
detail: { branch: "else" }
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async function sendEmail(step, context, env) {
|
|
310
|
+
if (!env.mailer) throw new ManabloxError("workflow.mail.notConfigured");
|
|
311
|
+
const recipients = /* @__PURE__ */ new Set();
|
|
312
|
+
for (const template of step.to) for (const address of render(template, context).split(/[,\s;]+/)) if (address && isEmailAddress(address)) recipients.add(address.toLowerCase());
|
|
313
|
+
if (step.toRoles.length) {
|
|
314
|
+
const members = await env.repos.users.membersOf(context.space.id);
|
|
315
|
+
for (const member of members) if (step.toRoles.includes(member.role) && member.user.email) recipients.add(member.user.email.toLowerCase());
|
|
316
|
+
}
|
|
317
|
+
if (recipients.size === 0) throw new Error("No recipient address resolved");
|
|
318
|
+
const subject = render(step.subject, context);
|
|
319
|
+
const body = render(step.body, context);
|
|
320
|
+
const to = [...recipients];
|
|
321
|
+
const sent = await env.mailer.send({
|
|
322
|
+
to,
|
|
323
|
+
subject,
|
|
324
|
+
text: step.html ? stripTags(body) : body,
|
|
325
|
+
...step.html ? { html: body } : {}
|
|
326
|
+
});
|
|
327
|
+
return {
|
|
328
|
+
kind: "ok",
|
|
329
|
+
message: `Sent to ${to.join(", ")}`,
|
|
330
|
+
detail: {
|
|
331
|
+
to,
|
|
332
|
+
subject,
|
|
333
|
+
messageId: sent.id
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function stripTags(html) {
|
|
338
|
+
return html.replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<br\s*\/?>/gi, "\n").replace(/<\/p>/gi, "\n\n").replace(/<[^>]+>/g, "").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").trim();
|
|
339
|
+
}
|
|
340
|
+
async function callHttp(step, context, env) {
|
|
341
|
+
const url = render(step.url, context);
|
|
342
|
+
const headers = new Headers();
|
|
343
|
+
for (const header of step.headers) headers.set(header.name, render(header.value, context));
|
|
344
|
+
let body;
|
|
345
|
+
if (step.method !== "GET" && step.body.mode !== "none") {
|
|
346
|
+
if (step.body.mode === "event") {
|
|
347
|
+
body = JSON.stringify(context);
|
|
348
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
349
|
+
} else {
|
|
350
|
+
const template = step.body.template;
|
|
351
|
+
body = looksLikeJson(template) ? renderJson(template, context) : render(template, context);
|
|
352
|
+
if (!headers.has("content-type")) headers.set("content-type", looksLikeJson(template) ? "application/json" : "text/plain");
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
headers.set("x-manablox-event", context.event);
|
|
356
|
+
headers.set("x-manablox-workflow", context.workflow.id);
|
|
357
|
+
if (step.secret && body !== void 0) headers.set("x-manablox-signature", `sha256=${createHmac("sha256", step.secret).update(body).digest("hex")}`);
|
|
358
|
+
const started = Date.now();
|
|
359
|
+
let response;
|
|
360
|
+
try {
|
|
361
|
+
response = await env.fetch(url, {
|
|
362
|
+
method: step.method,
|
|
363
|
+
headers,
|
|
364
|
+
...body !== void 0 ? { body } : {},
|
|
365
|
+
signal: AbortSignal.timeout(step.timeoutMs)
|
|
366
|
+
});
|
|
367
|
+
} catch (error) {
|
|
368
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
369
|
+
throw new Error(`${step.method} ${url} failed: ${reason}`);
|
|
370
|
+
}
|
|
371
|
+
const snippet = (await response.text().catch(() => "")).slice(0, 500);
|
|
372
|
+
const detail = {
|
|
373
|
+
url,
|
|
374
|
+
method: step.method,
|
|
375
|
+
status: response.status,
|
|
376
|
+
ms: Date.now() - started,
|
|
377
|
+
response: snippet
|
|
378
|
+
};
|
|
379
|
+
if (!response.ok) {
|
|
380
|
+
const error = /* @__PURE__ */ new Error(`${step.method} ${url} answered HTTP ${response.status}`);
|
|
381
|
+
error.detail = detail;
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
kind: "ok",
|
|
386
|
+
message: `HTTP ${response.status}`,
|
|
387
|
+
detail
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function looksLikeJson(template) {
|
|
391
|
+
const trimmed = template.trim();
|
|
392
|
+
if (!(trimmed.startsWith("{") && trimmed.endsWith("}")) && !(trimmed.startsWith("[") && trimmed.endsWith("]"))) return false;
|
|
393
|
+
try {
|
|
394
|
+
JSON.parse(trimmed);
|
|
395
|
+
return true;
|
|
396
|
+
} catch {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
async function sendPush(step, context, env) {
|
|
401
|
+
if (!env.pusher) throw new ManabloxError("workflow.push.notConfigured");
|
|
402
|
+
const userIds = new Set(step.userIds);
|
|
403
|
+
if (step.roles.length || userIds.size === 0) {
|
|
404
|
+
const members = await env.repos.users.membersOf(context.space.id);
|
|
405
|
+
for (const member of members) if (step.roles.length === 0 || step.roles.includes(member.role)) userIds.add(member.userId);
|
|
406
|
+
}
|
|
407
|
+
const subscriptions = await env.repos.workflows.subscriptionsFor([...userIds]);
|
|
408
|
+
if (subscriptions.length === 0) return {
|
|
409
|
+
kind: "ok",
|
|
410
|
+
message: "Nobody among the recipients has enabled notifications",
|
|
411
|
+
detail: {
|
|
412
|
+
recipients: userIds.size,
|
|
413
|
+
devices: 0
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
const renderedUrl = render(step.url, context).trim();
|
|
417
|
+
const url = renderedUrl ? /^https?:\/\//i.test(renderedUrl) ? renderedUrl : `${env.adminUrl.replace(/\/$/, "")}/${renderedUrl.replace(/^\//, "")}` : context.url;
|
|
418
|
+
const payload = {
|
|
419
|
+
title: render(step.title, context),
|
|
420
|
+
body: render(step.body, context),
|
|
421
|
+
url
|
|
422
|
+
};
|
|
423
|
+
let sent = 0;
|
|
424
|
+
let gone = 0;
|
|
425
|
+
const failures = [];
|
|
426
|
+
for (const subscription of subscriptions) try {
|
|
427
|
+
if (await env.pusher.send({
|
|
428
|
+
endpoint: subscription.endpoint,
|
|
429
|
+
keys: subscription.keys
|
|
430
|
+
}, payload) === "gone") {
|
|
431
|
+
gone++;
|
|
432
|
+
await env.repos.workflows.dropSubscription(subscription.id);
|
|
433
|
+
} else {
|
|
434
|
+
sent++;
|
|
435
|
+
await env.repos.workflows.markSubscriptionUsed(subscription.id);
|
|
436
|
+
}
|
|
437
|
+
} catch (error) {
|
|
438
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
439
|
+
}
|
|
440
|
+
const detail = {
|
|
441
|
+
recipients: userIds.size,
|
|
442
|
+
devices: subscriptions.length,
|
|
443
|
+
sent,
|
|
444
|
+
gone,
|
|
445
|
+
failures
|
|
446
|
+
};
|
|
447
|
+
if (sent === 0 && failures.length) {
|
|
448
|
+
const error = /* @__PURE__ */ new Error(`Every push failed: ${failures[0]}`);
|
|
449
|
+
error.detail = detail;
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
kind: "ok",
|
|
454
|
+
message: `Sent to ${sent} device${sent === 1 ? "" : "s"}`,
|
|
455
|
+
detail
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region src/engine.ts
|
|
460
|
+
/** The hook events a content change raises, and the trigger events each satisfies. */
|
|
461
|
+
const EVENT_ALIASES = {
|
|
462
|
+
"content.created": ["content.created", "content.saved"],
|
|
463
|
+
"content.updated": ["content.updated", "content.saved"],
|
|
464
|
+
"content.saved": ["content.saved"],
|
|
465
|
+
"content.deleted": ["content.deleted"],
|
|
466
|
+
"content.published": ["content.published"],
|
|
467
|
+
"content.unpublished": ["content.unpublished"]
|
|
468
|
+
};
|
|
469
|
+
/**
|
|
470
|
+
* Runs workflows: listens for content events, keeps the clock for scheduled ones, and
|
|
471
|
+
* executes a run's steps one after another, pausing at a delay and picking it up again.
|
|
472
|
+
*
|
|
473
|
+
* A run is a row from the moment it is queued, so nothing is lost if the process goes
|
|
474
|
+
* away mid-way: the next tick finds `waiting` runs whose time has come, and a worker
|
|
475
|
+
* claims a run before touching it so two never execute the same one.
|
|
476
|
+
*/
|
|
477
|
+
var WorkflowEngine = class {
|
|
478
|
+
manablox;
|
|
479
|
+
repos;
|
|
480
|
+
env;
|
|
481
|
+
dispatch;
|
|
482
|
+
now;
|
|
483
|
+
tickMs;
|
|
484
|
+
pending = /* @__PURE__ */ new Set();
|
|
485
|
+
timer = null;
|
|
486
|
+
ticking = false;
|
|
487
|
+
constructor(manablox, repos, options = {}) {
|
|
488
|
+
this.manablox = manablox;
|
|
489
|
+
this.repos = repos;
|
|
490
|
+
this.env = {
|
|
491
|
+
repos,
|
|
492
|
+
mailer: options.mailer ?? null,
|
|
493
|
+
pusher: options.pusher ?? null,
|
|
494
|
+
fetch: options.fetch ?? globalThis.fetch,
|
|
495
|
+
adminUrl: options.adminUrl ?? manablox.config.server.adminUrl
|
|
496
|
+
};
|
|
497
|
+
this.dispatch = options.dispatch ?? ((runId) => this.background(this.run(runId)));
|
|
498
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
499
|
+
this.tickMs = options.tickMs ?? 2e4;
|
|
500
|
+
}
|
|
501
|
+
get adminUrl() {
|
|
502
|
+
return this.env.adminUrl;
|
|
503
|
+
}
|
|
504
|
+
/** Registers the content hooks. Call once, at boot. */
|
|
505
|
+
attach() {
|
|
506
|
+
const source = "@manablox/workflows";
|
|
507
|
+
const on = (event) => async (row, context) => {
|
|
508
|
+
await this.onContentEvent(event, row, context);
|
|
509
|
+
};
|
|
510
|
+
this.manablox.hooks.on("content:afterCreate", on("content.created"), { source });
|
|
511
|
+
this.manablox.hooks.on("content:afterUpdate", on("content.updated"), { source });
|
|
512
|
+
this.manablox.hooks.on("content:afterPublish", on("content.published"), { source });
|
|
513
|
+
this.manablox.hooks.on("content:afterDelete", async (payload, context) => {
|
|
514
|
+
await this.onContentEvent("content.deleted", payload.record, context);
|
|
515
|
+
}, { source });
|
|
516
|
+
this.manablox.hooks.on("content:afterUnpublish", async (payload, context) => {
|
|
517
|
+
const row = await this.repos.content.findById(payload.id);
|
|
518
|
+
if (row) await this.onContentEvent("content.unpublished", row, context);
|
|
519
|
+
}, { source });
|
|
520
|
+
}
|
|
521
|
+
/** Starts the clock for scheduled workflows and paused runs. */
|
|
522
|
+
start() {
|
|
523
|
+
if (this.timer) return;
|
|
524
|
+
this.timer = setInterval(() => void this.tick(), this.tickMs);
|
|
525
|
+
this.timer.unref?.();
|
|
526
|
+
this.manablox.onDispose(() => this.stop());
|
|
527
|
+
}
|
|
528
|
+
async stop() {
|
|
529
|
+
if (this.timer) clearInterval(this.timer);
|
|
530
|
+
this.timer = null;
|
|
531
|
+
await this.idle();
|
|
532
|
+
}
|
|
533
|
+
/** Resolves once every run started in this process has finished. For tests and shutdown. */
|
|
534
|
+
async idle() {
|
|
535
|
+
while (this.pending.size) await Promise.allSettled([...this.pending]);
|
|
536
|
+
}
|
|
537
|
+
async onContentEvent(event, row, context) {
|
|
538
|
+
const matching = (await this.repos.workflows.listEnabled(row.spaceId)).filter((workflow) => matchesEvent(workflow.trigger, event, row));
|
|
539
|
+
if (matching.length === 0) return;
|
|
540
|
+
const space = await this.spaceInfo(row.spaceId);
|
|
541
|
+
const actor = await this.actorInfo(context.actor?.userId ?? null);
|
|
542
|
+
const previous = context.previous ? serialise(context.previous) : null;
|
|
543
|
+
for (const workflow of matching) {
|
|
544
|
+
const runContext = {
|
|
545
|
+
event,
|
|
546
|
+
workflow: {
|
|
547
|
+
id: workflow.id,
|
|
548
|
+
name: workflow.name
|
|
549
|
+
},
|
|
550
|
+
space,
|
|
551
|
+
content: serialise(row),
|
|
552
|
+
previous: event === "content.updated" ? previous : null,
|
|
553
|
+
documents: [],
|
|
554
|
+
actor,
|
|
555
|
+
url: event === "content.deleted" ? null : this.documentUrl(row.id),
|
|
556
|
+
at: this.now().toISOString()
|
|
557
|
+
};
|
|
558
|
+
await this.enqueue(workflow, event, runContext);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* One look at the clock: start every scheduled workflow whose cron names this minute,
|
|
563
|
+
* and resume every paused run whose time has come. Safe to call from several
|
|
564
|
+
* processes — each claim is an atomic update.
|
|
565
|
+
*/
|
|
566
|
+
async tick(now = this.now()) {
|
|
567
|
+
if (this.ticking) return;
|
|
568
|
+
this.ticking = true;
|
|
569
|
+
try {
|
|
570
|
+
const minute = floorToMinute(now);
|
|
571
|
+
const enabled = await this.repos.workflows.listEnabled();
|
|
572
|
+
for (const workflow of enabled) {
|
|
573
|
+
if (workflow.trigger.kind !== "schedule") continue;
|
|
574
|
+
const spec = parseCron(workflow.trigger.cron);
|
|
575
|
+
if (!spec || !cronMatches(spec, minute, workflow.trigger.timezone)) continue;
|
|
576
|
+
if (!await this.repos.workflows.claimSchedule(workflow.id, minute)) continue;
|
|
577
|
+
await this.startScheduled(workflow);
|
|
578
|
+
}
|
|
579
|
+
for (const run of await this.repos.workflows.dueRuns(now)) await this.dispatch(run.id);
|
|
580
|
+
} catch (error) {
|
|
581
|
+
this.manablox.logger.error({ err: error }, "workflow tick failed");
|
|
582
|
+
} finally {
|
|
583
|
+
this.ticking = false;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
async startScheduled(workflow) {
|
|
587
|
+
if (workflow.trigger.kind !== "schedule") return;
|
|
588
|
+
const space = await this.spaceInfo(workflow.spaceId);
|
|
589
|
+
const selection = workflow.trigger.selection;
|
|
590
|
+
const documents = selection ? (await this.repos.workflows.selectDocuments(workflow.spaceId, selection)).map(serialise) : [];
|
|
591
|
+
const base = {
|
|
592
|
+
event: "schedule",
|
|
593
|
+
workflow: {
|
|
594
|
+
id: workflow.id,
|
|
595
|
+
name: workflow.name
|
|
596
|
+
},
|
|
597
|
+
space,
|
|
598
|
+
previous: null,
|
|
599
|
+
actor: null,
|
|
600
|
+
at: this.now().toISOString()
|
|
601
|
+
};
|
|
602
|
+
if (workflow.trigger.perDocument) {
|
|
603
|
+
for (const document of documents) await this.enqueue(workflow, "schedule", {
|
|
604
|
+
...base,
|
|
605
|
+
content: document,
|
|
606
|
+
documents: [],
|
|
607
|
+
url: this.documentUrl(String(document.id))
|
|
608
|
+
});
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
await this.enqueue(workflow, "schedule", {
|
|
612
|
+
...base,
|
|
613
|
+
content: null,
|
|
614
|
+
documents,
|
|
615
|
+
url: null
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
/** A run started by hand from the editor, executed inline so the caller sees the log. */
|
|
619
|
+
async runManually(workflow, document) {
|
|
620
|
+
const space = await this.spaceInfo(workflow.spaceId);
|
|
621
|
+
const documents = workflow.trigger.kind === "schedule" && workflow.trigger.selection && !document ? (await this.repos.workflows.selectDocuments(workflow.spaceId, workflow.trigger.selection)).map(serialise) : [];
|
|
622
|
+
const run = await this.repos.workflows.createRun({
|
|
623
|
+
workflowId: workflow.id,
|
|
624
|
+
spaceId: workflow.spaceId,
|
|
625
|
+
trigger: "manual",
|
|
626
|
+
context: {
|
|
627
|
+
event: "manual",
|
|
628
|
+
workflow: {
|
|
629
|
+
id: workflow.id,
|
|
630
|
+
name: workflow.name
|
|
631
|
+
},
|
|
632
|
+
space,
|
|
633
|
+
content: document ? serialise(document) : null,
|
|
634
|
+
previous: null,
|
|
635
|
+
documents,
|
|
636
|
+
actor: null,
|
|
637
|
+
url: document ? this.documentUrl(document.id) : null,
|
|
638
|
+
at: this.now().toISOString()
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
await this.run(run.id);
|
|
642
|
+
return await this.repos.workflows.findRun(run.id) ?? run;
|
|
643
|
+
}
|
|
644
|
+
async enqueue(workflow, trigger, context) {
|
|
645
|
+
const run = await this.repos.workflows.createRun({
|
|
646
|
+
workflowId: workflow.id,
|
|
647
|
+
spaceId: workflow.spaceId,
|
|
648
|
+
trigger,
|
|
649
|
+
context
|
|
650
|
+
});
|
|
651
|
+
await this.dispatch(run.id);
|
|
652
|
+
}
|
|
653
|
+
background(work) {
|
|
654
|
+
const tracked = work.catch((error) => {
|
|
655
|
+
this.manablox.logger.error({ err: error }, "workflow run crashed");
|
|
656
|
+
}).finally(() => {
|
|
657
|
+
this.pending.delete(tracked);
|
|
658
|
+
});
|
|
659
|
+
this.pending.add(tracked);
|
|
660
|
+
return Promise.resolve();
|
|
661
|
+
}
|
|
662
|
+
/** Executes a queued or paused run from where it stands. The worker entry point. */
|
|
663
|
+
async run(runId) {
|
|
664
|
+
const run = await this.repos.workflows.claimRun(runId);
|
|
665
|
+
if (!run) return;
|
|
666
|
+
const workflow = await this.repos.workflows.findById(run.workflowId);
|
|
667
|
+
if (!workflow) {
|
|
668
|
+
await this.repos.workflows.saveRunProgress(run.id, {
|
|
669
|
+
status: "failed",
|
|
670
|
+
cursor: run.cursor,
|
|
671
|
+
log: run.log,
|
|
672
|
+
error: "The workflow no longer exists",
|
|
673
|
+
finished: true
|
|
674
|
+
});
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const actor = {
|
|
678
|
+
kind: "workflow",
|
|
679
|
+
id: workflow.id,
|
|
680
|
+
label: workflow.name,
|
|
681
|
+
detail: {
|
|
682
|
+
runId: run.id,
|
|
683
|
+
trigger: run.trigger
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
await runAsActor(actor, async () => {
|
|
687
|
+
const log = [...run.log];
|
|
688
|
+
const outcome = await this.runChain(run, workflow, workflow.steps, [], run.cursor, log);
|
|
689
|
+
if (outcome.kind === "done") await this.finish(run, workflow, "succeeded", [], log, null);
|
|
690
|
+
else if (outcome.kind === "stop") await this.finish(run, workflow, "skipped", outcome.cursor, log, null);
|
|
691
|
+
else if (outcome.kind === "fail") await this.finish(run, workflow, "failed", outcome.cursor, log, outcome.error);
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Walks one chain — the top level, or a side of a fork — from `resume` when the run is
|
|
696
|
+
* being picked up inside it. A fork's chosen side is walked recursively; the position
|
|
697
|
+
* saved at a delay is the full path, so a resume finds its way back down.
|
|
698
|
+
*/
|
|
699
|
+
async runChain(run, workflow, steps, prefix, resume, log) {
|
|
700
|
+
let index = 0;
|
|
701
|
+
let inner = null;
|
|
702
|
+
if (resume?.length) {
|
|
703
|
+
index = resume[0];
|
|
704
|
+
const side = resume[1];
|
|
705
|
+
if (side === "then" || side === "else") inner = {
|
|
706
|
+
side,
|
|
707
|
+
cursor: resume.slice(2)
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
for (; index < steps.length; index++) {
|
|
711
|
+
const step = steps[index];
|
|
712
|
+
if (!step) break;
|
|
713
|
+
const here = [...prefix, index];
|
|
714
|
+
const startedAt = this.now();
|
|
715
|
+
const entry = (status, message, detail = null) => ({
|
|
716
|
+
stepId: step.id,
|
|
717
|
+
type: step.type,
|
|
718
|
+
name: step.name,
|
|
719
|
+
status,
|
|
720
|
+
message,
|
|
721
|
+
detail,
|
|
722
|
+
startedAt: startedAt.toISOString(),
|
|
723
|
+
ms: this.now().getTime() - startedAt.getTime()
|
|
724
|
+
});
|
|
725
|
+
if (inner) {
|
|
726
|
+
const { side, cursor } = inner;
|
|
727
|
+
inner = null;
|
|
728
|
+
if (step.type === "branch") {
|
|
729
|
+
const outcome = await this.runChain(run, workflow, step[side], [...here, side], cursor, log);
|
|
730
|
+
if (outcome.kind !== "done") return outcome;
|
|
731
|
+
}
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if (!step.enabled) {
|
|
735
|
+
log.push(entry("skipped", "Step is switched off"));
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (step.type === "branch") {
|
|
739
|
+
const holds = evaluateCondition(step, run.context);
|
|
740
|
+
const side = holds ? "then" : "else";
|
|
741
|
+
log.push(entry("ok", holds ? "Rules hold — taking the yes side" : "Rules do not hold — taking the no side", { branch: side }));
|
|
742
|
+
const outcome = await this.runChain(run, workflow, step[side], [...here, side], null, log);
|
|
743
|
+
if (outcome.kind !== "done") return outcome;
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
try {
|
|
747
|
+
const outcome = await executeStep(step, run.context, this.env);
|
|
748
|
+
if (outcome.kind === "wait") {
|
|
749
|
+
const resumeAt = new Date(this.now().getTime() + outcome.minutes * 6e4);
|
|
750
|
+
log.push(entry("waiting", `Waiting until ${resumeAt.toISOString()}`, { resumeAt: resumeAt.toISOString() }));
|
|
751
|
+
await this.repos.workflows.saveRunProgress(run.id, {
|
|
752
|
+
status: "waiting",
|
|
753
|
+
cursor: [...prefix, index + 1],
|
|
754
|
+
log,
|
|
755
|
+
resumeAt
|
|
756
|
+
});
|
|
757
|
+
return { kind: "wait" };
|
|
758
|
+
}
|
|
759
|
+
if (outcome.kind === "stop") {
|
|
760
|
+
log.push(entry("stopped", outcome.message));
|
|
761
|
+
return {
|
|
762
|
+
kind: "stop",
|
|
763
|
+
cursor: here
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
log.push(entry("ok", outcome.message, outcome.detail));
|
|
767
|
+
} catch (error) {
|
|
768
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
769
|
+
const detail = error.detail ?? null;
|
|
770
|
+
log.push(entry("failed", message, detail));
|
|
771
|
+
this.manablox.logger.warn({
|
|
772
|
+
workflow: workflow.id,
|
|
773
|
+
run: run.id,
|
|
774
|
+
step: step.id,
|
|
775
|
+
err: error
|
|
776
|
+
}, "workflow step failed");
|
|
777
|
+
if (!step.continueOnError) return {
|
|
778
|
+
kind: "fail",
|
|
779
|
+
cursor: here,
|
|
780
|
+
error: message
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return { kind: "done" };
|
|
785
|
+
}
|
|
786
|
+
async finish(run, workflow, status, cursor, log, error) {
|
|
787
|
+
await this.repos.workflows.saveRunProgress(run.id, {
|
|
788
|
+
status,
|
|
789
|
+
cursor,
|
|
790
|
+
log,
|
|
791
|
+
error,
|
|
792
|
+
finished: true
|
|
793
|
+
});
|
|
794
|
+
await this.repos.workflows.touchRun(workflow.id, this.now());
|
|
795
|
+
await this.manablox.hooks.run("workflow:afterRun", {
|
|
796
|
+
runId: run.id,
|
|
797
|
+
workflowId: workflow.id,
|
|
798
|
+
spaceId: workflow.spaceId,
|
|
799
|
+
status,
|
|
800
|
+
trigger: run.trigger
|
|
801
|
+
}, {
|
|
802
|
+
manablox: this.manablox,
|
|
803
|
+
spaceId: workflow.spaceId
|
|
804
|
+
});
|
|
805
|
+
await this.repos.audit.record({
|
|
806
|
+
spaceId: workflow.spaceId,
|
|
807
|
+
action: "workflowRun.finish",
|
|
808
|
+
targetKind: "workflowRun",
|
|
809
|
+
targetId: run.id,
|
|
810
|
+
targetLabel: workflow.name,
|
|
811
|
+
meta: {
|
|
812
|
+
workflowId: workflow.id,
|
|
813
|
+
status,
|
|
814
|
+
trigger: run.trigger,
|
|
815
|
+
error,
|
|
816
|
+
contentId: run.context.content?.id ?? null,
|
|
817
|
+
steps: log.map((entry) => ({
|
|
818
|
+
step: entry.name || entry.type,
|
|
819
|
+
status: entry.status
|
|
820
|
+
}))
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
async spaceInfo(spaceId) {
|
|
825
|
+
const space = await this.repos.spaces.findById(spaceId);
|
|
826
|
+
return space ? {
|
|
827
|
+
id: space.id,
|
|
828
|
+
name: space.name,
|
|
829
|
+
machineName: space.machineName,
|
|
830
|
+
url: space.url
|
|
831
|
+
} : {
|
|
832
|
+
id: spaceId,
|
|
833
|
+
name: "",
|
|
834
|
+
machineName: "",
|
|
835
|
+
url: ""
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
async actorInfo(userId) {
|
|
839
|
+
if (!userId) return null;
|
|
840
|
+
const user = await this.repos.users.findById(userId);
|
|
841
|
+
return user ? {
|
|
842
|
+
id: user.id,
|
|
843
|
+
name: user.name,
|
|
844
|
+
email: user.email
|
|
845
|
+
} : null;
|
|
846
|
+
}
|
|
847
|
+
documentUrl(contentId) {
|
|
848
|
+
return `${this.env.adminUrl.replace(/\/$/, "")}/content/${contentId}`;
|
|
849
|
+
}
|
|
850
|
+
};
|
|
851
|
+
/** Whether an event trigger wants this event for this document. */
|
|
852
|
+
function matchesEvent(trigger, event, row) {
|
|
853
|
+
if (trigger.kind !== "event") return false;
|
|
854
|
+
const satisfied = EVENT_ALIASES[event];
|
|
855
|
+
if (!trigger.events.some((wanted) => satisfied.includes(wanted))) return false;
|
|
856
|
+
if (trigger.typeIds.length && !trigger.typeIds.includes(row.typeId)) return false;
|
|
857
|
+
if (trigger.locales.length && !trigger.locales.includes(row.locale)) return false;
|
|
858
|
+
return true;
|
|
859
|
+
}
|
|
860
|
+
/** A row as a template sees it: plain JSON, without the search machinery. */
|
|
861
|
+
function serialise(row) {
|
|
862
|
+
const { search: _search, searchText: _searchText, ...rest } = row;
|
|
863
|
+
return JSON.parse(JSON.stringify(rest));
|
|
864
|
+
}
|
|
865
|
+
//#endregion
|
|
866
|
+
//#region src/mail.ts
|
|
867
|
+
/**
|
|
868
|
+
* A mailer over the configured SMTP URL, or `null` when there is none — the step then
|
|
869
|
+
* fails with `workflow.mail.notConfigured`, which the run log shows verbatim.
|
|
870
|
+
*/
|
|
871
|
+
function createMailer(config, logger) {
|
|
872
|
+
if (!config.smtpUrl) return null;
|
|
873
|
+
const transport = nodemailer.createTransport(config.smtpUrl);
|
|
874
|
+
const from = config.from ?? "Manablox <no-reply@localhost>";
|
|
875
|
+
return { async send(message) {
|
|
876
|
+
const info = await transport.sendMail({
|
|
877
|
+
from,
|
|
878
|
+
to: message.to,
|
|
879
|
+
subject: message.subject,
|
|
880
|
+
text: message.text,
|
|
881
|
+
...message.html ? { html: message.html } : {}
|
|
882
|
+
});
|
|
883
|
+
logger.debug({
|
|
884
|
+
to: message.to,
|
|
885
|
+
id: info.messageId
|
|
886
|
+
}, "workflow mail sent");
|
|
887
|
+
return { id: info.messageId ?? null };
|
|
888
|
+
} };
|
|
889
|
+
}
|
|
890
|
+
//#endregion
|
|
891
|
+
//#region src/push.ts
|
|
892
|
+
function isPushConfigured(config) {
|
|
893
|
+
return Boolean(config.vapidPublicKey && config.vapidPrivateKey);
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* A Web Push sender over the configured VAPID keys, or `null` when there are none. The
|
|
897
|
+
* keys are made once per installation with `pnpm --filter @manablox/api push:keys`.
|
|
898
|
+
*/
|
|
899
|
+
function createPusher(config, logger) {
|
|
900
|
+
if (!config.vapidPublicKey || !config.vapidPrivateKey) return null;
|
|
901
|
+
const subject = config.subject ?? "mailto:admin@localhost";
|
|
902
|
+
const publicKey = config.vapidPublicKey;
|
|
903
|
+
const privateKey = config.vapidPrivateKey;
|
|
904
|
+
return {
|
|
905
|
+
publicKey,
|
|
906
|
+
async send(target, payload) {
|
|
907
|
+
try {
|
|
908
|
+
await webpush.sendNotification(target, JSON.stringify(payload), {
|
|
909
|
+
vapidDetails: {
|
|
910
|
+
subject,
|
|
911
|
+
publicKey,
|
|
912
|
+
privateKey
|
|
913
|
+
},
|
|
914
|
+
TTL: 86400
|
|
915
|
+
});
|
|
916
|
+
return "sent";
|
|
917
|
+
} catch (error) {
|
|
918
|
+
const status = error.statusCode;
|
|
919
|
+
if (status === 404 || status === 410) {
|
|
920
|
+
logger.debug({ endpoint: target.endpoint }, "push subscription gone");
|
|
921
|
+
return "gone";
|
|
922
|
+
}
|
|
923
|
+
throw error;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
/** A fresh VAPID key pair, for the CLI that prints them. */
|
|
929
|
+
function generatePushKeys() {
|
|
930
|
+
return webpush.generateVAPIDKeys();
|
|
931
|
+
}
|
|
932
|
+
//#endregion
|
|
933
|
+
//#region src/validate.ts
|
|
934
|
+
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
935
|
+
const MAX_DELAY_MINUTES = 43200;
|
|
936
|
+
/**
|
|
937
|
+
* Checks everything the shape alone cannot: an event that exists, a cron that parses, a
|
|
938
|
+
* step with somewhere to send to. Every problem is reported at once, each with the path
|
|
939
|
+
* of the field it concerns, so the editor can mark them all in one round.
|
|
940
|
+
*/
|
|
941
|
+
function validateWorkflow(input, env) {
|
|
942
|
+
const problems = [];
|
|
943
|
+
const add = (key, path, params) => problems.push({
|
|
944
|
+
key,
|
|
945
|
+
path,
|
|
946
|
+
...params ? { params } : {}
|
|
947
|
+
});
|
|
948
|
+
const name = input.name.trim();
|
|
949
|
+
if (!name) add("workflow.name.required", ["name"]);
|
|
950
|
+
const trigger = validateTrigger(input.trigger, env, add);
|
|
951
|
+
const steps = validateSteps(input.steps, ["steps"], add);
|
|
952
|
+
if (steps.length === 0) add("workflow.steps.required", ["steps"]);
|
|
953
|
+
if (problems.length) throw ManabloxError.validation(problems, "workflow.validation.failed");
|
|
954
|
+
return {
|
|
955
|
+
name,
|
|
956
|
+
description: input.description?.trim() || null,
|
|
957
|
+
enabled: input.enabled ?? false,
|
|
958
|
+
trigger,
|
|
959
|
+
steps
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
function validateTrigger(trigger, env, add) {
|
|
963
|
+
if (trigger.kind === "schedule") {
|
|
964
|
+
if (!isValidCron(trigger.cron)) add("workflow.trigger.cronInvalid", ["trigger", "cron"]);
|
|
965
|
+
if (!isValidTimezone(trigger.timezone)) add("workflow.trigger.timezoneInvalid", ["trigger", "timezone"]);
|
|
966
|
+
for (const [index, typeId] of (trigger.selection?.typeIds ?? []).entries()) if (!env.typeExists(typeId)) add("workflow.trigger.typeNotFound", [
|
|
967
|
+
"trigger",
|
|
968
|
+
"selection",
|
|
969
|
+
"typeIds",
|
|
970
|
+
index
|
|
971
|
+
], { typeId });
|
|
972
|
+
return {
|
|
973
|
+
kind: "schedule",
|
|
974
|
+
cron: trigger.cron.trim(),
|
|
975
|
+
timezone: trigger.timezone,
|
|
976
|
+
selection: trigger.selection ? {
|
|
977
|
+
typeIds: trigger.selection.typeIds,
|
|
978
|
+
status: trigger.selection.status,
|
|
979
|
+
changedWithinHours: trigger.selection.changedWithinHours ?? null,
|
|
980
|
+
locale: trigger.selection.locale || null
|
|
981
|
+
} : null,
|
|
982
|
+
perDocument: Boolean(trigger.perDocument && trigger.selection)
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
const events = [...new Set(trigger.events)];
|
|
986
|
+
if (events.length === 0) add("workflow.trigger.eventsRequired", ["trigger", "events"]);
|
|
987
|
+
for (const [index, event] of events.entries()) if (!WORKFLOW_EVENTS.includes(event)) add("workflow.trigger.eventUnknown", [
|
|
988
|
+
"trigger",
|
|
989
|
+
"events",
|
|
990
|
+
index
|
|
991
|
+
], { event });
|
|
992
|
+
for (const [index, typeId] of trigger.typeIds.entries()) if (!env.typeExists(typeId)) add("workflow.trigger.typeNotFound", [
|
|
993
|
+
"trigger",
|
|
994
|
+
"typeIds",
|
|
995
|
+
index
|
|
996
|
+
], { typeId });
|
|
997
|
+
return {
|
|
998
|
+
kind: "event",
|
|
999
|
+
events,
|
|
1000
|
+
typeIds: trigger.typeIds,
|
|
1001
|
+
locales: trigger.locales ?? []
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
/** Checks every step of a chain, recursing into the two sides of a fork. */
|
|
1005
|
+
function validateSteps(steps, prefix, add) {
|
|
1006
|
+
return steps.map((step, index) => validateStep(step, [...prefix, index], add));
|
|
1007
|
+
}
|
|
1008
|
+
function validateRules(step, at, add) {
|
|
1009
|
+
if (step.rules.length === 0) add("workflow.step.condition.rulesRequired", at("rules"));
|
|
1010
|
+
for (const [i, rule] of step.rules.entries()) {
|
|
1011
|
+
if (!rule.field.trim()) add("workflow.step.condition.fieldRequired", at("rules", i, "field"));
|
|
1012
|
+
if (!WORKFLOW_CONDITION_OPERATORS.includes(rule.operator)) add("workflow.step.typeUnknown", at("rules", i, "operator"), { type: rule.operator });
|
|
1013
|
+
}
|
|
1014
|
+
return {
|
|
1015
|
+
match: step.match === "any" ? "any" : "all",
|
|
1016
|
+
rules: step.rules.map((rule) => ({
|
|
1017
|
+
field: rule.field.trim(),
|
|
1018
|
+
operator: rule.operator,
|
|
1019
|
+
value: rule.value ?? ""
|
|
1020
|
+
}))
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
function validateStep(step, path, add) {
|
|
1024
|
+
const at = (...rest) => [...path, ...rest];
|
|
1025
|
+
const base = {
|
|
1026
|
+
id: step.id || randomUUID(),
|
|
1027
|
+
name: (step.name ?? "").trim(),
|
|
1028
|
+
enabled: step.enabled ?? true,
|
|
1029
|
+
continueOnError: step.continueOnError ?? false
|
|
1030
|
+
};
|
|
1031
|
+
if (!WORKFLOW_STEP_TYPES.includes(step.type)) {
|
|
1032
|
+
add("workflow.step.typeUnknown", at("type"), { type: step.type });
|
|
1033
|
+
return step;
|
|
1034
|
+
}
|
|
1035
|
+
switch (step.type) {
|
|
1036
|
+
case "email": {
|
|
1037
|
+
const to = step.to.map((address) => address.trim()).filter(Boolean);
|
|
1038
|
+
const toRoles = step.toRoles.map((role) => role.trim()).filter(Boolean);
|
|
1039
|
+
if (to.length === 0 && toRoles.length === 0) add("workflow.step.email.recipientRequired", at("to"));
|
|
1040
|
+
for (const [i, address] of to.entries()) if (!address.includes("{{") && !isEmailAddress(address)) add("workflow.step.email.recipientInvalid", at("to", i), { address });
|
|
1041
|
+
if (!step.subject.trim()) add("workflow.step.email.subjectRequired", at("subject"));
|
|
1042
|
+
return {
|
|
1043
|
+
...base,
|
|
1044
|
+
type: "email",
|
|
1045
|
+
to,
|
|
1046
|
+
toRoles,
|
|
1047
|
+
subject: step.subject.trim(),
|
|
1048
|
+
body: step.body ?? "",
|
|
1049
|
+
html: Boolean(step.html)
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
case "http": {
|
|
1053
|
+
const url = step.url.trim();
|
|
1054
|
+
if (!url.includes("{{") && !isHttpUrl(url)) add("workflow.step.http.urlInvalid", at("url"));
|
|
1055
|
+
if (url.includes("{{") && !/^https?:\/\//i.test(url)) add("workflow.step.http.urlInvalid", at("url"));
|
|
1056
|
+
const headers = step.headers.map((header) => ({
|
|
1057
|
+
name: header.name.trim(),
|
|
1058
|
+
value: header.value
|
|
1059
|
+
})).filter((header) => header.name || header.value.trim());
|
|
1060
|
+
for (const [i, header] of headers.entries()) if (!HEADER_NAME.test(header.name)) add("workflow.step.http.headerNameInvalid", at("headers", i, "name"), { name: header.name });
|
|
1061
|
+
return {
|
|
1062
|
+
...base,
|
|
1063
|
+
type: "http",
|
|
1064
|
+
method: step.method,
|
|
1065
|
+
url,
|
|
1066
|
+
headers,
|
|
1067
|
+
body: {
|
|
1068
|
+
mode: step.body?.mode ?? "event",
|
|
1069
|
+
template: step.body?.template ?? ""
|
|
1070
|
+
},
|
|
1071
|
+
secret: step.secret?.trim() || null,
|
|
1072
|
+
timeoutMs: clamp(step.timeoutMs ?? 1e4, 1e3, 12e4)
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
case "push":
|
|
1076
|
+
if (!step.title.trim()) add("workflow.step.push.titleRequired", at("title"));
|
|
1077
|
+
return {
|
|
1078
|
+
...base,
|
|
1079
|
+
type: "push",
|
|
1080
|
+
roles: step.roles.map((role) => role.trim()).filter(Boolean),
|
|
1081
|
+
userIds: step.userIds.filter(Boolean),
|
|
1082
|
+
title: step.title.trim(),
|
|
1083
|
+
body: step.body ?? "",
|
|
1084
|
+
url: step.url?.trim() ?? ""
|
|
1085
|
+
};
|
|
1086
|
+
case "condition": return {
|
|
1087
|
+
...base,
|
|
1088
|
+
type: "condition",
|
|
1089
|
+
...validateRules(step, at, add)
|
|
1090
|
+
};
|
|
1091
|
+
case "branch": return {
|
|
1092
|
+
...base,
|
|
1093
|
+
type: "branch",
|
|
1094
|
+
...validateRules(step, at, add),
|
|
1095
|
+
then: validateSteps(step.then ?? [], at("then"), add),
|
|
1096
|
+
else: validateSteps(step.else ?? [], at("else"), add)
|
|
1097
|
+
};
|
|
1098
|
+
case "delay": {
|
|
1099
|
+
const minutes = Number(step.minutes);
|
|
1100
|
+
if (!Number.isFinite(minutes) || minutes < 1 || minutes > MAX_DELAY_MINUTES) add("workflow.step.delay.minutesInvalid", at("minutes"), { max: MAX_DELAY_MINUTES });
|
|
1101
|
+
return {
|
|
1102
|
+
...base,
|
|
1103
|
+
type: "delay",
|
|
1104
|
+
minutes: Math.round(minutes)
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
function isHttpUrl(value) {
|
|
1110
|
+
try {
|
|
1111
|
+
const url = new URL(value);
|
|
1112
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
1113
|
+
} catch {
|
|
1114
|
+
return false;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
1118
|
+
//#endregion
|
|
1119
|
+
//#region src/service.ts
|
|
1120
|
+
/** The scheduler's bookkeeping columns change on their own; the log records what people set. */
|
|
1121
|
+
const WORKFLOW_DIFF = { ignore: ["lastScheduledAt", "lastRunAt"] };
|
|
1122
|
+
/**
|
|
1123
|
+
* Workflows: the rules — a trigger that exists, steps with somewhere to go, a run that
|
|
1124
|
+
* belongs to the space it is asked for in — with the engine doing the running.
|
|
1125
|
+
*/
|
|
1126
|
+
var WorkflowService = class {
|
|
1127
|
+
manablox;
|
|
1128
|
+
repos;
|
|
1129
|
+
engine;
|
|
1130
|
+
constructor(manablox, repos, engine) {
|
|
1131
|
+
this.manablox = manablox;
|
|
1132
|
+
this.repos = repos;
|
|
1133
|
+
this.engine = engine;
|
|
1134
|
+
}
|
|
1135
|
+
catalog() {
|
|
1136
|
+
const mail = Boolean(this.manablox.config.mail.smtpUrl);
|
|
1137
|
+
const push = Boolean(this.manablox.config.push.vapidPublicKey && this.manablox.config.push.vapidPrivateKey);
|
|
1138
|
+
return {
|
|
1139
|
+
events: WORKFLOW_EVENTS.map((id) => ({
|
|
1140
|
+
id,
|
|
1141
|
+
...WORKFLOW_EVENT_LABELS[id]
|
|
1142
|
+
})),
|
|
1143
|
+
stepTypes: WORKFLOW_STEP_TYPES.map((id) => ({
|
|
1144
|
+
id,
|
|
1145
|
+
...WORKFLOW_STEP_LABELS[id],
|
|
1146
|
+
available: id === "email" ? mail : id === "push" ? push : true
|
|
1147
|
+
})),
|
|
1148
|
+
operators: WORKFLOW_CONDITION_OPERATORS.map((id) => ({
|
|
1149
|
+
id,
|
|
1150
|
+
label: WORKFLOW_CONDITION_OPERATOR_LABELS[id]
|
|
1151
|
+
})),
|
|
1152
|
+
mail,
|
|
1153
|
+
push,
|
|
1154
|
+
pushPublicKey: push ? this.manablox.config.push.vapidPublicKey ?? null : null,
|
|
1155
|
+
adminUrl: this.engine.adminUrl
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
list(spaceId) {
|
|
1159
|
+
return this.repos.workflows.listBySpace(spaceId);
|
|
1160
|
+
}
|
|
1161
|
+
get(spaceId, id) {
|
|
1162
|
+
return this.find(spaceId, id);
|
|
1163
|
+
}
|
|
1164
|
+
async create(spaceId, input) {
|
|
1165
|
+
const valid = validateWorkflow(input, this.validationEnvironment());
|
|
1166
|
+
const row = await this.repos.workflows.create({
|
|
1167
|
+
spaceId,
|
|
1168
|
+
...valid
|
|
1169
|
+
});
|
|
1170
|
+
await this.audit("workflow.create", row, snapshotChanges(row, "created", WORKFLOW_DIFF));
|
|
1171
|
+
return row;
|
|
1172
|
+
}
|
|
1173
|
+
async update(spaceId, id, input) {
|
|
1174
|
+
const before = await this.find(spaceId, id);
|
|
1175
|
+
const valid = validateWorkflow(input, this.validationEnvironment());
|
|
1176
|
+
const row = await this.repos.workflows.update(id, valid);
|
|
1177
|
+
await this.audit("workflow.update", row, diffRecords(before, row, WORKFLOW_DIFF));
|
|
1178
|
+
return row;
|
|
1179
|
+
}
|
|
1180
|
+
/** The on/off switch, kept apart from a full save so the list can flip it. */
|
|
1181
|
+
async setEnabled(spaceId, id, enabled) {
|
|
1182
|
+
const before = await this.find(spaceId, id);
|
|
1183
|
+
const row = await this.repos.workflows.update(id, { enabled });
|
|
1184
|
+
await this.audit("workflow.setEnabled", row, [{
|
|
1185
|
+
path: "enabled",
|
|
1186
|
+
from: before.enabled,
|
|
1187
|
+
to: row.enabled
|
|
1188
|
+
}]);
|
|
1189
|
+
return row;
|
|
1190
|
+
}
|
|
1191
|
+
async delete(spaceId, id) {
|
|
1192
|
+
const row = await this.find(spaceId, id);
|
|
1193
|
+
await this.repos.workflows.delete(id);
|
|
1194
|
+
await this.audit("workflow.delete", row, snapshotChanges(row, "deleted", WORKFLOW_DIFF));
|
|
1195
|
+
}
|
|
1196
|
+
async runs(spaceId, id, limit = 50) {
|
|
1197
|
+
await this.find(spaceId, id);
|
|
1198
|
+
return this.repos.workflows.listRuns(id, limit);
|
|
1199
|
+
}
|
|
1200
|
+
async run(spaceId, id) {
|
|
1201
|
+
return requireInSpace(await this.repos.workflows.findRun(id), spaceId, "workflow.run.notFound", { id });
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Runs a workflow now, against a document of the space when one is named. An
|
|
1205
|
+
* event-triggered workflow needs one — its steps read `content` — while a scheduled
|
|
1206
|
+
* workflow may run against its own selection.
|
|
1207
|
+
*/
|
|
1208
|
+
async runNow(spaceId, id, contentId) {
|
|
1209
|
+
const workflow = await this.find(spaceId, id);
|
|
1210
|
+
let document = null;
|
|
1211
|
+
if (contentId) document = requireInSpace(await this.repos.content.findById(contentId), spaceId, "content.notFound", { id: contentId });
|
|
1212
|
+
else if (workflow.trigger.kind === "event") throw ManabloxError.badRequest("workflow.run.documentRequired");
|
|
1213
|
+
const run = await this.engine.runManually(workflow, document);
|
|
1214
|
+
await this.audit("workflow.runNow", workflow, [], {
|
|
1215
|
+
runId: run.id,
|
|
1216
|
+
status: run.status,
|
|
1217
|
+
contentId: document?.id ?? null
|
|
1218
|
+
});
|
|
1219
|
+
return run;
|
|
1220
|
+
}
|
|
1221
|
+
subscriptions(userId) {
|
|
1222
|
+
return this.repos.workflows.subscriptionsOf(userId);
|
|
1223
|
+
}
|
|
1224
|
+
async subscribe(userId, subscription, userAgent) {
|
|
1225
|
+
if (!this.catalog().push) throw ManabloxError.badRequest("workflow.push.notConfigured");
|
|
1226
|
+
if (!/^https:\/\//.test(subscription.endpoint)) throw ManabloxError.badRequest("workflow.push.subscriptionInvalid");
|
|
1227
|
+
return this.repos.workflows.subscribe({
|
|
1228
|
+
userId,
|
|
1229
|
+
...subscription,
|
|
1230
|
+
userAgent
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
unsubscribe(userId, endpoint) {
|
|
1234
|
+
return this.repos.workflows.unsubscribe(userId, endpoint);
|
|
1235
|
+
}
|
|
1236
|
+
audit(action, workflow, changes, meta = null) {
|
|
1237
|
+
return this.repos.audit.record({
|
|
1238
|
+
spaceId: workflow.spaceId,
|
|
1239
|
+
action,
|
|
1240
|
+
targetKind: "workflow",
|
|
1241
|
+
targetId: workflow.id,
|
|
1242
|
+
targetLabel: workflow.name,
|
|
1243
|
+
changes,
|
|
1244
|
+
meta
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
async find(spaceId, id) {
|
|
1248
|
+
return requireInSpace(await this.repos.workflows.findById(id), spaceId, "workflow.notFound", { id });
|
|
1249
|
+
}
|
|
1250
|
+
validationEnvironment() {
|
|
1251
|
+
return { typeExists: (typeId) => this.manablox.contentTypes.tryGet(typeId) !== void 0 };
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
//#endregion
|
|
1255
|
+
export { WorkflowEngine, WorkflowService, createMailer, createPusher, cronMatches, evaluateCondition, evaluateRule, executeStep, floorToMinute, generatePushKeys, isPushConfigured, isValidCron, isValidTimezone, matchesEvent, parseCron, placeholders, render, renderJson, resolvePath, serialise, stringify, validateWorkflow, wallClock };
|