diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index a448a9c7..c57f4939 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -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: 'admin@example.com' }, + }); + 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: 'admin@example.com' }).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); + }); }); diff --git a/backend/server.js b/backend/server.js index 7709e394..9200a1a7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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')); diff --git a/backend/src/routes/publicWorkflowApprovals.js b/backend/src/routes/publicWorkflowApprovals.js new file mode 100644 index 00000000..f9bbfd00 --- /dev/null +++ b/backend/src/routes/publicWorkflowApprovals.js @@ -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 `` + + `` + + `${title}` + + `` + + `

${title}

${body}

`; +} + +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; diff --git a/backend/src/services/workflows/approvals.js b/backend/src/services/workflows/approvals.js new file mode 100644 index 00000000..667478f9 --- /dev/null +++ b/backend/src/services/workflows/approvals.js @@ -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 }; diff --git a/backend/src/services/workflows/index.js b/backend/src/services/workflows/index.js index 1c386ed3..e41742bf 100644 --- a/backend/src/services/workflows/index.js +++ b/backend/src/services/workflows/index.js @@ -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, };