@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.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(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/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 };