Browse Source

feat: a Wait for Approval node that pauses an execution for a person

fszontagh 1 month ago
parent
commit
a4b65432a0

+ 106 - 0
nodes/core/wait-for-approval.js

@@ -0,0 +1,106 @@
+/**
+ * @node wait-for-approval
+ * @name Wait for Approval
+ * @category flow-control
+ * @version 1.0.0
+ * @description Pause the execution until someone answers, then continue with what they sent
+ * @icon user-check
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        reason: {
+            type: 'string',
+            title: 'Reason',
+            description: 'What the approver is being asked to decide. Supports {{variable}} interpolation',
+            format: 'textarea'
+        },
+        expiresIn: {
+            type: 'number',
+            title: 'Expires In (hours)',
+            description: 'How long the request stays answerable. 0 means it never expires, though the execution is still capped at 30 days',
+            default: 24
+        },
+        fields: {
+            type: 'array',
+            title: 'Fields To Collect',
+            description: 'Extra values the approver is asked for, sent back on the answer',
+            items: {
+                type: 'object',
+                properties: {
+                    name: { type: 'string', title: 'Name' },
+                    title: { type: 'string', title: 'Label' },
+                    type: { type: 'string', title: 'Type', enum: ['string', 'number', 'boolean'], default: 'string' }
+                }
+            }
+        }
+    }
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        token: { type: 'string', description: 'Token that must be presented to answer this request' },
+        reason: { type: 'string' },
+        expiresAt: { type: 'number', description: 'Milliseconds since the epoch, 0 when it never expires' },
+        approved: { type: 'boolean', description: 'Set on the answer, after the execution resumes' },
+        answeredBy: { type: 'string', description: 'Set on the answer' },
+        answeredAt: { type: 'number', description: 'Set on the answer' }
+    }
+};
+
+const MAX_EXPIRY_HOURS = 30 * 24;
+
+async function execute(config, input, context) {
+    // Loop iterations keep their state in engine locals that the stored
+    // execution does not describe, so a pause inside one could be recorded but
+    // never resumed. Failing here is far better than accepting an approval that
+    // can never be answered.
+    if (input && (input.index !== undefined || input.currentIndex !== undefined)) {
+        throw new Error('Wait for Approval: this node cannot be used inside a Loop body, ' +
+            'because a paused loop iteration cannot be resumed. Collect the items first, ' +
+            'approve once, then loop');
+    }
+
+    const hours = config.expiresIn === undefined || config.expiresIn === null || config.expiresIn === ''
+        ? 24
+        : Number(config.expiresIn);
+    if (isNaN(hours) || hours < 0) {
+        throw new Error('Wait for Approval: expiresIn must be a number of hours, got "' +
+            config.expiresIn + '"');
+    }
+    if (hours > MAX_EXPIRY_HOURS) {
+        throw new Error('Wait for Approval: expiresIn is capped at ' + MAX_EXPIRY_HOURS +
+            ' hours. An approval nobody answers should eventually stop occupying the queue');
+    }
+
+    const token = smartbotic.utils.uuid();
+    const expiresAt = hours === 0 ? 0 : Date.now() + Math.round(hours * 60 * 60 * 1000);
+    const reason = config.reason ? String(config.reason) : 'Approval required';
+    const fields = Array.isArray(config.fields) ? config.fields : [];
+
+    smartbotic.log.info('Wait for Approval: pausing for "' + reason + '", token ' + token);
+
+    return {
+        token: token,
+        reason: reason,
+        expiresAt: expiresAt,
+        fields: fields,
+        _pause: {
+            token: token,
+            reason: reason,
+            expiresAt: expiresAt,
+            fields: fields
+        }
+    };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 20 - 0
tests/nodes/wait-for-approval-loop.json

@@ -0,0 +1,20 @@
+{
+  "name": "verify-wait-for-approval-rejects-loop",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "items", "name": "Items", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { items: [1, 2] };"}},
+    {"id": "loop", "name": "Loop", "type": "loop", "position": {"x": 0, "y": 200},
+     "config": {"inputField": "data.result.items"}},
+    {"id": "approve", "name": "Approve", "type": "wait-for-approval", "position": {"x": 0, "y": 300},
+     "config": {"reason": "Should never pause", "expiresIn": 1}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "items", "targetInput": "data"},
+    {"sourceNodeId": "items", "sourceOutput": "main", "targetNodeId": "loop", "targetInput": "data"},
+    {"sourceNodeId": "loop", "sourceOutput": "loop", "targetNodeId": "approve", "targetInput": "data"}
+  ],
+  "expect": {
+    "approve": {"status": "failed", "errorContains": "cannot be used inside a Loop body"}
+  }
+}

+ 22 - 0
tests/nodes/wait-for-approval.json

@@ -0,0 +1,22 @@
+{
+  "name": "verify-wait-for-approval",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "approve", "name": "Approve", "type": "wait-for-approval", "position": {"x": 0, "y": 100},
+     "config": {"reason": "Approve the test", "expiresIn": 1}},
+    {"id": "after", "name": "After Approval", "type": "code", "position": {"x": 0, "y": 200},
+     "config": {"code": "return { sawApproval: input.data.approved === true, note: input.data.note };"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "approve", "targetInput": "data"},
+    {"sourceNodeId": "approve", "sourceOutput": "main", "targetNodeId": "after", "targetInput": "data"}
+  ],
+  "resume": {
+    "waitForStatus": "waiting",
+    "tokenFrom": "approve",
+    "payload": {"approved": true, "data": {"note": "looks fine"}}
+  },
+  "expect": {
+    "after": {"status": "completed", "output": {"result": {"sawApproval": true, "note": "looks fine"}}}
+  }
+}