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:
@@ -187,4 +187,50 @@ describe('workflow engine', () => {
|
||||
expect(await cond(makeCtx({ paid_at: null, status: 'paid' }))).toBe(true);
|
||||
expect(await cond(makeCtx({ paid_at: null, status: 'sent', paid_amount_minor: 0, total_amount_minor: 1000 }))).toBe(false);
|
||||
});
|
||||
|
||||
test('gate creates a pending approval + admin email, token confirm resumes the run', async () => {
|
||||
await makeWorkflow({
|
||||
trigger: 'approval.event',
|
||||
nodes: [
|
||||
{ key: 'a1', type: 'trigger' },
|
||||
{ key: 'a2', type: 'gate', config: { type: 'payment_confirm', prompt: 'No payment yet?' } },
|
||||
{ key: 'a3', type: 'action', config: { action: 'noop' } }, // confirm path
|
||||
{ key: 'a4', type: 'action', config: { action: 'noop' } }, // deny path
|
||||
],
|
||||
edges: [
|
||||
{ from: 'a1', to: 'a2' },
|
||||
{ from: 'a2', handle: 'confirm', to: 'a3' },
|
||||
{ from: 'a2', handle: 'deny', to: 'a4' },
|
||||
],
|
||||
});
|
||||
const runIds = await engine.emitWorkflowEvent('approval.event', {
|
||||
entityType: 'invoice', entityId: 42, payload: { adminEmail: '[email protected]' },
|
||||
});
|
||||
const runId = runIds[0];
|
||||
|
||||
let run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('waiting');
|
||||
expect(run.current_node).toBe('a2');
|
||||
|
||||
const approval = await db('workflow_approvals').where({ run_id: runId }).first();
|
||||
expect(approval).toBeTruthy();
|
||||
expect(approval.status).toBe('pending');
|
||||
|
||||
const adminMail = await db('email_queue').where({ recipient_email: '[email protected]' }).first();
|
||||
expect(adminMail).toBeTruthy();
|
||||
|
||||
// Extract the raw token from the emailed confirm link and act on it.
|
||||
const data = JSON.parse(adminMail.email_data);
|
||||
const rawToken = data.confirm_url.split('/').slice(-2)[0];
|
||||
const res = await engine.actByToken(rawToken, 'confirm');
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.status).toBe('confirmed');
|
||||
|
||||
run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('done');
|
||||
|
||||
// A second click is idempotent (already recorded).
|
||||
const again = await engine.actByToken(rawToken, 'confirm');
|
||||
expect(again.already).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -718,6 +718,7 @@ app.use('/api/admin/dev', require('./src/routes/adminDev'));
|
||||
app.use('/api/public/quotes', require('./src/routes/publicQuotes'));
|
||||
app.use('/api/public/contracts', require('./src/routes/publicContracts'));
|
||||
app.use('/api/public/payment-check', require('./src/routes/publicPaymentCheck'));
|
||||
app.use('/api/public/workflow-approvals', require('./src/routes/publicWorkflowApprovals'));
|
||||
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
|
||||
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
|
||||
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Public workflow-approval endpoint — the confirm/deny links emailed to the
|
||||
* admin when a workflow gate is reached. Token is the single-use raw value
|
||||
* (hashed at rest); the action resumes the run down the matching edge.
|
||||
*
|
||||
* GET is used so the link is clickable from an email client. The token is
|
||||
* single-use and the handler is idempotent (a second click shows "already
|
||||
* recorded"), so prefetching can't double-act.
|
||||
*/
|
||||
const express = require('express');
|
||||
|
||||
const router = express.Router();
|
||||
const { actByToken } = require('../services/workflows');
|
||||
|
||||
function page(title, body) {
|
||||
return `<!doctype html><html><head><meta charset="utf-8">`
|
||||
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`
|
||||
+ `<title>${title}</title></head>`
|
||||
+ `<body style="font-family:system-ui,sans-serif;max-width:480px;margin:64px auto;padding:0 20px;text-align:center;color:#1f2937">`
|
||||
+ `<h2 style="font-weight:600">${title}</h2><p style="color:#4b5563;line-height:1.6">${body}</p></body></html>`;
|
||||
}
|
||||
|
||||
router.get('/:token/:action', async (req, res) => {
|
||||
const { token, action } = req.params;
|
||||
if (!['confirm', 'deny'].includes(action)) {
|
||||
return res.status(400).send(page('Invalid link', 'This confirmation link is not valid.'));
|
||||
}
|
||||
try {
|
||||
const result = await actByToken(token, action);
|
||||
if (!result.ok && result.reason === 'not_found') {
|
||||
return res.status(404).send(page('Link not found', 'This confirmation link is invalid or has been revoked.'));
|
||||
}
|
||||
if (!result.ok && result.reason === 'expired') {
|
||||
return res.status(410).send(page('Link expired', 'This confirmation link has expired. Use the workflow inbox in the admin panel instead.'));
|
||||
}
|
||||
if (result.already) {
|
||||
return res.send(page('Already recorded', `This request was already ${result.status}.`));
|
||||
}
|
||||
return res.send(page(
|
||||
'Thank you',
|
||||
action === 'confirm'
|
||||
? 'Confirmed — the workflow will continue.'
|
||||
: 'Recorded — the workflow has been told there is no payment / to stop.',
|
||||
));
|
||||
} catch (e) {
|
||||
return res.status(500).send(page('Something went wrong', 'We could not record your response. Please try again or use the admin panel.'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user