feat(workflows): approval gates — email confirm/deny + token resume

gate_setup action creates a workflow_approvals row (single-use token stored
as SHA-256 hash) and emails the admin confirm/deny links immediately
(internal mail, no business-hours floor). actByToken / actById finalize the
approval and resume the run down the matching confirm/deny edge; both are
idempotent (a second click → 'already recorded') and respect expiry. Public
GET /api/public/workflow-approvals/:token/:action returns a small HTML
confirmation page (clickable from email, single-use so prefetch can't
double-act). listPending backs the webview inbox (wired in the CRUD phase).
Test covers gate→approval→email→token-confirm→resume + idempotency.
This commit is contained in:
Luca
2026-06-23 02:37:20 +02:00
parent cc0ba5347d
commit b48d8c2eb8
5 changed files with 226 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
/**
* Workflow approval gates — the human-in-the-loop step.
*
* When the engine hits a `gate` node it calls the registered `gate_setup`
* action, which creates a workflow_approvals row (single-use token stored as a
* SHA-256 hash) and emails the admin a confirm/deny link. The run stays
* `waiting` until the admin acts — via the email link (actByToken) or the
* webview pending-approvals inbox (actById) — at which point the run resumes
* down the matching confirm/deny edge.
*
* Internal/admin mail → sent immediately (respectBusinessHours: false).
*/
const crypto = require('crypto');
const { db } = require('../../database/db');
const logger = require('../../utils/logger');
const registry = require('./registry');
const engine = require('./engine');
function hashToken(raw) {
return crypto.createHash('sha256').update(String(raw)).digest('hex');
}
/**
* gate_setup action — create the approval + email the admin. Called by the
* engine when a gate node is reached. Best-effort on the email; the approval
* row (and thus the inbox path) is always created.
*/
async function createApproval(ctx) {
const { run, node } = ctx;
const cfg = node.config || {};
const raw = crypto.randomBytes(32).toString('hex');
const expiresAt = cfg.timeoutDays
? new Date(Date.now() + Number(cfg.timeoutDays) * 86400000).toISOString()
: null;
await db('workflow_approvals').insert({
run_id: run.id,
node_key: node.node_key,
type: cfg.type || 'payment_confirm',
status: 'pending',
token_hash: hashToken(raw),
payload: JSON.stringify({ prompt: cfg.prompt || null, vars: ctx.vars || {} }),
expires_at: expiresAt,
created_at: db.fn.now(),
});
try {
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
const base = (await getFrontendBaseUrl()) || '';
const confirmUrl = `${base}/api/public/workflow-approvals/${raw}/confirm`;
const denyUrl = `${base}/api/public/workflow-approvals/${raw}/deny`;
let adminEmail = ctx.vars?.adminEmail || null;
if (!adminEmail) {
const bp = await db('business_profile').where({ id: 1 }).first('email');
adminEmail = bp?.email || null;
}
if (adminEmail) {
const emailProcessor = require('../emailProcessor');
await emailProcessor.queueEmail(
ctx.vars?.eventId || null,
adminEmail,
cfg.emailType || 'workflow_approval',
{
prompt: cfg.prompt || 'A workflow needs your confirmation.',
confirm_url: confirmUrl,
deny_url: denyUrl,
...(ctx.vars?.emailData || {}),
},
{ respectBusinessHours: false }, // internal/admin → immediate
);
} else {
logger.warn('[workflow] approval created but no admin email to notify', { runId: run.id });
}
} catch (e) {
logger.error('[workflow] approval email failed', { runId: run.id, error: e.message });
}
return { approval: true };
}
registry.registerAction('gate_setup', createApproval);
async function finalizeApproval(approval, decision, actorPatch) {
if (!approval) return { ok: false, reason: 'not_found' };
if (approval.status !== 'pending') return { ok: true, already: true, status: approval.status };
if (approval.expires_at && new Date(approval.expires_at).getTime() < Date.now()) {
await db('workflow_approvals').where({ id: approval.id }).update({ status: 'expired' });
return { ok: false, reason: 'expired' };
}
const status = decision === 'confirm' ? 'confirmed' : 'denied';
await db('workflow_approvals').where({ id: approval.id })
.update({ status, acted_at: db.fn.now(), ...actorPatch });
// Resume down the matching edge (handles 'confirm' | 'deny').
await engine.resumeRun(approval.run_id, { decisionHandle: decision });
return { ok: true, status };
}
/** Act on an approval via the emailed single-use token. */
async function actByToken(rawToken, decision) {
const approval = await db('workflow_approvals').where({ token_hash: hashToken(rawToken) }).first();
return finalizeApproval(approval, decision, { acted_via: 'email' });
}
/** Act on an approval from the admin webview inbox. */
async function actById(id, decision, adminId) {
const approval = await db('workflow_approvals').where({ id }).first();
return finalizeApproval(approval, decision, { acted_via: 'web', acted_by: adminId || null });
}
/** Pending approvals for the webview inbox, newest first, with workflow name. */
async function listPending(limit = 100) {
return db('workflow_approvals as a')
.join('workflow_runs as r', 'r.id', 'a.run_id')
.join('workflows as w', 'w.id', 'r.workflow_id')
.where('a.status', 'pending')
.select(
'a.id', 'a.type', 'a.payload', 'a.created_at', 'a.expires_at',
'r.id as run_id', 'r.entity_type', 'r.entity_id',
'w.id as workflow_id', 'w.name as workflow_name',
)
.orderBy('a.created_at', 'desc')
.limit(limit);
}
module.exports = { hashToken, createApproval, actByToken, actById, listPending };
+3
View File
@@ -12,8 +12,11 @@ const registry = require('./registry');
// Side-effect import: registers the data-touching action/condition handlers
// (send_email, invoice_paid, prepare_* document actions) onto the registry.
require('./actions');
// Registers the gate_setup action + exposes approval helpers.
const approvals = require('./approvals');
module.exports = {
...engine,
...approvals,
registry,
};