From ff478619b580096d4dfb46dd6ad751daead082fb Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:53:03 +0200 Subject: [PATCH 01/43] feat(workflows): add workflows feature flag + Features-tab toggle New opt-in 'workflows' master flag (default off) across the backend KNOWN_FLAGS/DEFAULT_FLAGS and the frontend FeatureKey union, context defaults, and a new Automation section card in the Features tab. Gates the upcoming Workflows admin area and the engine runtime. en/de i18n added (DE native). --- backend/src/routes/adminFeatureFlags.js | 6 ++++++ frontend/src/contexts/FeatureFlagsContext.tsx | 3 +++ .../features/settings/tabs/FeaturesTab.tsx | 19 +++++++++++++++++++ frontend/src/i18n/locales/de.json | 8 +++++++- frontend/src/i18n/locales/en.json | 8 +++++++- frontend/src/services/featureFlags.service.ts | 6 +++++- 6 files changed, 47 insertions(+), 3 deletions(-) diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js index 1b3badd0..65114458 100644 --- a/backend/src/routes/adminFeatureFlags.js +++ b/backend/src/routes/adminFeatureFlags.js @@ -88,6 +88,11 @@ const KNOWN_FLAGS = [ // per-event-type presets and global watermark defaults tab. Strictly opt-in; // gates all slideshow admin UI (per-event card, type preset, settings tab). 'slideshow', + // Workflow / automation engine — admin-configurable visual flows (triggers, + // conditions, branches, loops, approval gates). Strictly opt-in; master + // kill-switch for the Workflows admin area AND the engine's runtime side + // effects (no run is created/resumed while off). + 'workflows', ]; // Spec defaults for any flag missing from the DB (e.g. a row added by a @@ -117,6 +122,7 @@ const DEFAULT_FLAGS = { projects: false, whatsapp: false, slideshow: false, + workflows: false, }; async function readAllFlags() { diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx index c97d4c1b..218d81d2 100644 --- a/frontend/src/contexts/FeatureFlagsContext.tsx +++ b/frontend/src/contexts/FeatureFlagsContext.tsx @@ -64,6 +64,9 @@ export const DEFAULT_FLAGS: FeatureFlags = { whatsapp: false, // Live Slideshow ("Diashow") — opt-in; gates all slideshow admin UI. slideshow: false, + // Workflow / automation engine — opt-in; gates the Workflows admin area + // and the engine runtime (triggers/actions/gates). + workflows: false, }; export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const; diff --git a/frontend/src/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx index 5816bb3f..9434b923 100644 --- a/frontend/src/features/settings/tabs/FeaturesTab.tsx +++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx @@ -23,6 +23,7 @@ import { Wallet, FolderKanban, MonitorPlay, + Workflow, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button, Card } from '../../../components/common'; @@ -134,6 +135,24 @@ export const FeaturesTab: React.FC = () => { /> + {/* Automation — the visual workflow engine. Master kill-switch for the + Workflows admin area and the runtime; off by default. */} +
+ setFlag('workflows', next)} + /> +
+ {/* Clients (#354 follow-up). Visual grouping for the CRM-area sub-features. The "Clients" sidebar section itself is gated by a derived `clients` flag (computed from whether any diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 0f2b9671..9b680e94 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1663,7 +1663,8 @@ "accounting": "Buchhaltung", "insights": "Auswertungen & Zugriff", "customers": "Kunden", - "clients": "CRM" + "clients": "CRM", + "automation": "Automatisierung" }, "status": { "stable": "stabil", @@ -1779,6 +1780,11 @@ "slideshow": { "title": "Live-Diashow", "description": "Ein separater Vollbild-„Diashow“-Link pro Event für Beamer bei Live-Events – übernimmt neue Uploads automatisch, mit Voreinstellungen je Event-Typ und globalen Wasserzeichen-Vorgaben unter Einstellungen → Diashow." + }, + "workflows": { + "title": "Workflows", + "description": "Visuelle Automatisierungen auf einer Canvas erstellen – Auslöser, Bedingungen, Verzweigungen, Schleifen und Freigabe-Gates für Admins. Deine Mahnstufen und Buchungsschritte werden zu bearbeitbaren Abläufen. Strikt optional.", + "sidebar": "Workflows" } }, "customerSurface": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 1eeee74f..cf3c178b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1219,7 +1219,8 @@ "accounting": "Accounting", "insights": "Insights & Access", "customers": "Customers", - "clients": "CRM" + "clients": "CRM", + "automation": "Automation" }, "status": { "stable": "stable", @@ -1335,6 +1336,11 @@ "slideshow": { "title": "Live Slideshow", "description": "A separate fullscreen \"Diashow\" link per event for projectors at live events — auto-picks-up new uploads, with per-event-type presets and global watermark defaults under Settings → Slideshow." + }, + "workflows": { + "title": "Workflows", + "description": "Build visual automations on a canvas — triggers, conditions, branches, loops and admin approval gates. Your reminder ladder and booking steps become editable flows. Strictly opt-in.", + "sidebar": "Workflows" } }, "customerSurface": { diff --git a/frontend/src/services/featureFlags.service.ts b/frontend/src/services/featureFlags.service.ts index 176c83d8..ee396eab 100644 --- a/frontend/src/services/featureFlags.service.ts +++ b/frontend/src/services/featureFlags.service.ts @@ -66,7 +66,11 @@ export type FeatureKey = | 'whatsapp' // Live Slideshow ("Diashow") — per-event fullscreen kiosk link + presets + // global watermark settings tab. Strictly opt-in; gates all slideshow UI. - | 'slideshow'; + | 'slideshow' + // Workflow / automation engine — admin-configurable visual flows (triggers, + // conditions, branches, loops, approval gates) built on a canvas. Strictly + // opt-in; gates the Workflows admin area and the engine runtime. + | 'workflows'; export type FeatureFlags = Record; From c818c25cf2d7a9c9b281107a92528d5923758ab0 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:57:06 +0200 Subject: [PATCH 02/43] feat(workflows): schema + permissions (migration 142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the workflow engine's graph data model — workflows, workflow_nodes, workflow_edges (versioned so in-flight runs keep their version), workflow_runs (status/current_node/context, wake_at for the scheduler, unique dedup_key for idempotency), workflow_run_steps (per-node audit), and workflow_approvals (hashed email confirm/deny token + webview inbox). Seeds workflows.view / workflows.manage and grants them to super_admin + admin. Loose-FK integers per the whatsapp_queue/expenses convention; idempotent hasTable guards + reversible down(). --- .../core/142_create_workflow_tables.js | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 backend/migrations/core/142_create_workflow_tables.js diff --git a/backend/migrations/core/142_create_workflow_tables.js b/backend/migrations/core/142_create_workflow_tables.js new file mode 100644 index 00000000..da08da78 --- /dev/null +++ b/backend/migrations/core/142_create_workflow_tables.js @@ -0,0 +1,213 @@ +/** + * Migration 142: Workflow / automation engine schema + permissions. + * + * An admin-configurable visual flow engine (trigger → conditions → ordered + * steps with branching, loops, waits and approval gates). Strictly opt-in via + * the `workflows` feature flag (default off; no run is created/resumed while + * off). See docs / project_workflow_engine_requirements. + * + * Graph model (canvas, not a list): + * - workflows : one row per flow (name, enabled, current `version`, + * trigger_type + trigger_config). Built-ins (e.g. the + * dunning ladder) carry is_builtin + builtin_key. + * - workflow_nodes : nodes of a flow VERSION (node_key, type, config, x/y). + * - workflow_edges : edges of a flow VERSION (from_node[+handle] → to_node). + * Versioned so in-flight runs keep executing the version they started on + * (editing bumps workflows.version and writes a fresh node/edge set). + * - workflow_runs : one execution (pinned version, entity, status, + * current_node, context JSON, wake_at for delays, + * dedup_key to prevent double-fire on re-tick). + * - workflow_run_steps: per-node audit trail (observability + System Health). + * - workflow_approvals: human gates — token_hash for the email confirm/deny + * link (hashed at rest) + the webview inbox. + * + * Loose-FK integers (no DB-level FK) by design, matching whatsapp_queue / + * inbound_documents / expenses — the service cascades child deletes in a + * transaction. Idempotent: every createTable is hasTable-guarded; the + * permission seed mirrors migration 123. + */ +const NEW_PERMISSIONS = [ + { + name: 'workflows.view', + display_name: 'View Workflows', + category: 'workflows', + description: 'View automation workflows, their runs and pending approvals', + }, + { + name: 'workflows.manage', + display_name: 'Manage Workflows', + category: 'workflows', + description: 'Create, edit, enable/disable workflows and act on approval gates', + }, +]; + +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('workflows'))) { + await knex.schema.createTable('workflows', (table) => { + table.increments('id').primary(); + table.string('name', 255).notNullable(); + table.text('description'); + table.boolean('enabled').notNullable().defaultTo(false); + // Current/latest graph version. Editing bumps this; runs pin the value + // they started on so an edit never rewrites a flow mid-run. + table.integer('version').notNullable().defaultTo(1); + table.string('trigger_type', 64).notNullable(); + table.json('trigger_config'); + // Seeded built-ins (e.g. the converted reminder ladder) are flagged so a + // boot self-heal can find/upsert them by a stable key. + table.boolean('is_builtin').notNullable().defaultTo(false); + table.string('builtin_key', 64); + table.integer('created_by').unsigned(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.index(['enabled', 'trigger_type'], 'workflows_trigger_index'); + table.index(['builtin_key']); + }); + } + + if (!(await knex.schema.hasTable('workflow_nodes'))) { + await knex.schema.createTable('workflow_nodes', (table) => { + table.increments('id').primary(); + table.integer('workflow_id').unsigned().notNullable(); + table.integer('version').notNullable().defaultTo(1); + // Stable id within the graph (edges + runs.current_node reference it). + table.string('node_key', 64).notNullable(); + // trigger | condition | branch | loop | wait | action | gate | webhook + table.string('type', 32).notNullable(); + table.json('config'); + table.integer('pos_x').notNullable().defaultTo(0); + table.integer('pos_y').notNullable().defaultTo(0); + table.unique(['workflow_id', 'version', 'node_key'], 'workflow_nodes_key_unique'); + table.index(['workflow_id', 'version'], 'workflow_nodes_graph_index'); + }); + } + + if (!(await knex.schema.hasTable('workflow_edges'))) { + await knex.schema.createTable('workflow_edges', (table) => { + table.increments('id').primary(); + table.integer('workflow_id').unsigned().notNullable(); + table.integer('version').notNullable().defaultTo(1); + table.string('from_node', 64).notNullable(); + // Output handle for multi-path nodes (yes/no, confirm/deny, ≥max/continue). + table.string('from_handle', 32); + table.string('to_node', 64).notNullable(); + table.string('label', 64); + // True for the loop-back edge so the canvas can render it distinctly. + table.boolean('loop_back').notNullable().defaultTo(false); + table.index(['workflow_id', 'version'], 'workflow_edges_graph_index'); + }); + } + + if (!(await knex.schema.hasTable('workflow_runs'))) { + await knex.schema.createTable('workflow_runs', (table) => { + table.increments('id').primary(); + table.integer('workflow_id').unsigned().notNullable(); + // Pinned graph version this run executes. + table.integer('version').notNullable(); + table.string('trigger_event', 64).notNullable(); + table.string('entity_type', 64); + table.integer('entity_id').unsigned(); + // pending | running | waiting | done | failed | cancelled + table.string('status', 20).notNullable().defaultTo('pending'); + table.string('current_node', 64); + table.json('context'); + // Idempotency: prevents a re-emitted/re-ticked trigger from double-firing. + table.string('dedup_key', 191).unique(); + // When a waiting run (delay or gate timeout) should be resumed by the + // scheduler. NULL while running/done. + table.timestamp('wake_at'); + table.timestamp('started_at').defaultTo(knex.fn.now()); + table.timestamp('finished_at'); + table.text('error'); + // Scheduler poll path: waiting runs whose wake_at has passed. + table.index(['status', 'wake_at'], 'workflow_runs_wake_index'); + table.index(['entity_type', 'entity_id'], 'workflow_runs_entity_index'); + table.index(['workflow_id']); + }); + } + + if (!(await knex.schema.hasTable('workflow_run_steps'))) { + await knex.schema.createTable('workflow_run_steps', (table) => { + table.increments('id').primary(); + table.integer('run_id').unsigned().notNullable(); + table.string('node_key', 64).notNullable(); + table.string('node_type', 32); + // done | failed | skipped | waiting + table.string('status', 20).notNullable().defaultTo('pending'); + table.json('result'); + table.text('error'); + table.timestamp('started_at').defaultTo(knex.fn.now()); + table.timestamp('finished_at'); + table.index(['run_id'], 'workflow_run_steps_run_index'); + }); + } + + if (!(await knex.schema.hasTable('workflow_approvals'))) { + await knex.schema.createTable('workflow_approvals', (table) => { + table.increments('id').primary(); + table.integer('run_id').unsigned().notNullable(); + table.string('node_key', 64).notNullable(); + table.string('type', 32).notNullable().defaultTo('payment_confirm'); + // pending | confirmed | denied | expired + table.string('status', 20).notNullable().defaultTo('pending'); + // SHA-256 hex of the single-use email confirm/deny token (hash-on-store). + table.string('token_hash', 128).notNullable(); + table.json('payload'); + table.timestamp('expires_at'); + table.integer('acted_by').unsigned(); + table.string('acted_via', 16); + table.timestamp('acted_at'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.unique(['token_hash'], 'workflow_approvals_token_unique'); + table.index(['status'], 'workflow_approvals_status_index'); + table.index(['run_id']); + }); + } + + // --- Permissions (idempotent, mirrors migration 123) --- + if (await knex.schema.hasTable('permissions')) { + const names = NEW_PERMISSIONS.map((p) => p.name); + const existing = await knex('permissions').whereIn('name', names).select('name'); + const existingSet = new Set(existing.map((r) => r.name)); + const toInsert = NEW_PERMISSIONS.filter((p) => !existingSet.has(p.name)); + if (toInsert.length > 0) await knex('permissions').insert(toInsert); + + if ((await knex.schema.hasTable('roles')) && (await knex.schema.hasTable('role_permissions'))) { + const roles = await knex('roles').whereIn('name', ['super_admin', 'admin']).select('id'); + const perms = await knex('permissions').whereIn('name', names).select('id'); + if (roles.length && perms.length) { + const existingGrants = await knex('role_permissions') + .whereIn('role_id', roles.map((r) => r.id)) + .whereIn('permission_id', perms.map((p) => p.id)) + .select('role_id', 'permission_id'); + const grantSet = new Set(existingGrants.map((g) => `${g.role_id}:${g.permission_id}`)); + const toGrant = []; + for (const r of roles) { + for (const p of perms) { + if (!grantSet.has(`${r.id}:${p.id}`)) { + toGrant.push({ role_id: r.id, permission_id: p.id }); + } + } + } + if (toGrant.length > 0) await knex('role_permissions').insert(toGrant); + } + } + } +}; + +exports.down = async function (knex) { + if (await knex.schema.hasTable('permissions')) { + const names = NEW_PERMISSIONS.map((p) => p.name); + const perms = await knex('permissions').whereIn('name', names).select('id'); + if (perms.length && (await knex.schema.hasTable('role_permissions'))) { + await knex('role_permissions').whereIn('permission_id', perms.map((p) => p.id)).del(); + } + await knex('permissions').whereIn('name', names).del(); + } + await knex.schema.dropTableIfExists('workflow_approvals'); + await knex.schema.dropTableIfExists('workflow_run_steps'); + await knex.schema.dropTableIfExists('workflow_runs'); + await knex.schema.dropTableIfExists('workflow_edges'); + await knex.schema.dropTableIfExists('workflow_nodes'); + await knex.schema.dropTableIfExists('workflows'); +}; From 1eaef67c36a1a1eae6d574d2d44f2a3bdfb43d4c Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:05:34 +0200 Subject: [PATCH 03/43] feat(workflows): execution engine core + registry + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Graph executor that walks nodes/edges per run: trigger, condition/branch (registered conditions → yes/no edge), bounded loop (counter in context + maxIterations cap), wait (status=waiting + wake_at for the scheduler), gate (status=waiting; resumed via confirm/deny edge), action/webhook (registered handlers). emitWorkflowEvent creates one idempotent run per matching enabled workflow (unique dedup_key) and fails CLOSED if the flag system is unavailable; never throws into callers (safe to call after commit). Every node records a workflow_run_steps row. Registry seeds primitive conditions (always/never/expr) + actions (noop/log/set_context). Integration test covers loop+wait resume, gate confirm, and dedup. --- .../integration/workflowEngine.test.js | 137 +++++++++ backend/src/services/workflows/engine.js | 284 ++++++++++++++++++ backend/src/services/workflows/index.js | 16 + backend/src/services/workflows/registry.js | 61 ++++ 4 files changed, 498 insertions(+) create mode 100644 backend/__tests__/integration/workflowEngine.test.js create mode 100644 backend/src/services/workflows/engine.js create mode 100644 backend/src/services/workflows/index.js create mode 100644 backend/src/services/workflows/registry.js diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js new file mode 100644 index 00000000..65119b6f --- /dev/null +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -0,0 +1,137 @@ +/** + * Workflow engine — graph execution integration tests. + * + * Exercises the engine against a real (temp SQLite) DB with migration 142 + * applied: branching, bounded loops, wait pauses + scheduler-style resume, + * gate pauses + confirm/deny resume, dedup idempotency, and step recording. + */ +const { bootCrmDb } = require('./helpers/crmDb'); + +let db; +let cleanup; +let engine; + +async function makeWorkflow({ nodes, edges, trigger = 'test.event', enabled = true }) { + const ins = await db('workflows').insert({ name: 'wf', trigger_type: trigger, version: 1, enabled }); + const workflowId = ins[0]; + for (const n of nodes) { + await db('workflow_nodes').insert({ + workflow_id: workflowId, version: 1, node_key: n.key, type: n.type, + config: JSON.stringify(n.config || {}), + }); + } + for (const e of edges) { + await db('workflow_edges').insert({ + workflow_id: workflowId, version: 1, from_node: e.from, from_handle: e.handle || null, to_node: e.to, + loop_back: e.loopBack || false, + }); + } + return workflowId; +} + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + // Engine requires the singleton db — require AFTER bootCrmDb wired the test path. + engine = require('../../src/services/workflows'); + // Enable the workflows flag so emitWorkflowEvent doesn't fail closed. + await db('feature_flags').insert({ key: 'workflows', value: true }); +}); + +afterAll(async () => { await cleanup(); }); + +describe('workflow engine', () => { + test('condition + bounded loop + wait pauses, resumes to completion', async () => { + // trigger → set paid=false → condition(paid?) --no--> loop(max2) + // loop --loop--> reminder(noop) → wait → (back to condition) + // loop --exit--> lateFee(noop) → end + // condition --yes--> lateFee (paid path, not taken here) + const wfId = await makeWorkflow({ + nodes: [ + { key: 'n1', type: 'trigger' }, + { key: 'n2', type: 'action', config: { action: 'set_context', set: { paid: false } } }, + { key: 'n3', type: 'condition', config: { condition: 'expr', field: 'paid', op: 'truthy' } }, + { key: 'n4', type: 'loop', config: { maxIterations: 2 } }, + { key: 'n5', type: 'action', config: { action: 'noop' } }, + { key: 'n6', type: 'wait', config: { delayMinutes: 0 } }, + { key: 'n7', type: 'action', config: { action: 'noop' } }, + ], + edges: [ + { from: 'n1', to: 'n2' }, + { from: 'n2', to: 'n3' }, + { from: 'n3', handle: 'no', to: 'n4' }, + { from: 'n3', handle: 'yes', to: 'n7' }, + { from: 'n4', handle: 'loop', to: 'n5' }, + { from: 'n4', handle: 'exit', to: 'n7' }, + { from: 'n5', to: 'n6' }, + { from: 'n6', to: 'n3', loopBack: true }, + ], + }); + + const runIds = await engine.emitWorkflowEvent('test.event', { entityType: 'invoice', entityId: 1 }); + expect(runIds.length).toBe(1); + const runId = runIds[0]; + + let run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('waiting'); // paused at first wait (loop iter 1) + expect(run.current_node).toBe('n6'); + + await engine.resumeRun(runId); + run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('waiting'); // paused again (loop iter 2) + + await engine.resumeRun(runId); + run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('done'); // loop exhausted → exit → end + + const ctx = JSON.parse(run.context); + expect(ctx.vars.__loop_n4).toBe(3); // counter incremented past the cap + void wfId; + + const steps = await db('workflow_run_steps').where({ run_id: runId }); + expect(steps.length).toBeGreaterThan(0); + }); + + test('emit is idempotent on dedup_key', async () => { + await makeWorkflow({ + trigger: 'dedup.event', + nodes: [{ key: 'n1', type: 'trigger' }, { key: 'n2', type: 'action', config: { action: 'noop' } }], + edges: [{ from: 'n1', to: 'n2' }], + }); + const first = await engine.emitWorkflowEvent('dedup.event', { entityType: 'x', entityId: 9 }); + const second = await engine.emitWorkflowEvent('dedup.event', { entityType: 'x', entityId: 9 }); + expect(first.length).toBe(1); + expect(second.length).toBe(0); // same entity → no duplicate run + }); + + test('gate pauses and resumes via the confirm edge', async () => { + const wfId = await makeWorkflow({ + trigger: 'gate.event', + nodes: [ + { key: 'g1', type: 'trigger' }, + { key: 'g2', type: 'gate', config: { type: 'payment_confirm' } }, + { key: 'g3', type: 'action', config: { action: 'noop' } }, + { key: 'g4', type: 'action', config: { action: 'noop' } }, + ], + edges: [ + { from: 'g1', to: 'g2' }, + { from: 'g2', handle: 'confirm', to: 'g3' }, + { from: 'g2', handle: 'deny', to: 'g4' }, + ], + }); + // create + start a run directly + await db('workflow_runs').insert({ + workflow_id: wfId, version: 1, trigger_event: 'gate.event', status: 'pending', + context: JSON.stringify({ vars: {} }), dedup_key: 'gate-test', + }); + const run0 = await db('workflow_runs').where({ dedup_key: 'gate-test' }).first(); + await engine.startRun(run0.id); + + let run = await db('workflow_runs').where({ id: run0.id }).first(); + expect(run.status).toBe('waiting'); + expect(run.current_node).toBe('g2'); + + await engine.resumeRun(run0.id, { decisionHandle: 'confirm' }); + run = await db('workflow_runs').where({ id: run0.id }).first(); + expect(run.status).toBe('done'); + }); +}); diff --git a/backend/src/services/workflows/engine.js b/backend/src/services/workflows/engine.js new file mode 100644 index 00000000..97caa737 --- /dev/null +++ b/backend/src/services/workflows/engine.js @@ -0,0 +1,284 @@ +/** + * Workflow execution engine — walks a flow GRAPH (nodes + edges) per run. + * + * Node types: trigger | condition/branch | loop | wait | gate | action | webhook. + * - condition/branch: run a registered condition → follow the yes/no edge. + * - loop: increment a per-node counter in run.context → follow loop/exit edge + * (bounded by config.maxIterations — no infinite runs). + * - wait: set status='waiting' + wake_at; the scheduler resumes it later. + * - gate: set status='waiting'; an approval (email confirm/deny or the webview + * inbox) resumes it via the matching confirm/deny edge. + * - action/webhook: dispatch to a registered action handler. + * + * Runs are idempotent (unique dedup_key per trigger+entity) and every node + * writes a workflow_run_steps row for observability / System Health. Designed + * to be called AFTER the caller's DB commit (emit never throws into callers). + */ +const { db } = require('../../database/db'); +const logger = require('../../utils/logger'); +const registry = require('./registry'); + +const MAX_STEPS_PER_ADVANCE = 200; + +function parseJson(value, fallback) { + if (value == null) return fallback; + if (typeof value === 'object') return value; + try { return JSON.parse(value); } catch (e) { return fallback; } +} + +async function loadGraph(workflowId, version) { + const nodes = await db('workflow_nodes').where({ workflow_id: workflowId, version }); + const edges = await db('workflow_edges').where({ workflow_id: workflowId, version }); + const nodeByKey = new Map(nodes.map((n) => [n.node_key, { ...n, config: parseJson(n.config, {}) }])); + return { nodeByKey, edges }; +} + +// Pick the outgoing edge from `fromNode`. With a handle, prefer the matching +// handle; otherwise fall back to a default (null-handle) edge or the sole edge. +function outEdge(edges, fromNode, handle) { + const candidates = edges.filter((e) => e.from_node === fromNode); + if (handle != null) { + const exact = candidates.find((e) => (e.from_handle || null) === handle); + if (exact) return exact; + } + return candidates.find((e) => e.from_handle == null) || (candidates.length === 1 ? candidates[0] : null); +} + +function computeWakeAt(config = {}, vars = {}) { + const cfg = config || {}; + if (cfg.untilVar && vars[cfg.untilVar]) return new Date(vars[cfg.untilVar]).toISOString(); + const ms = (Number(cfg.delayDays || 0) * 86400000) + + (Number(cfg.delayHours || 0) * 3600000) + + (Number(cfg.delayMinutes || 0) * 60000); + return new Date(Date.now() + ms).toISOString(); +} + +function gateTimeout(config = {}) { + const days = Number((config || {}).timeoutDays || 0); + return days > 0 ? new Date(Date.now() + days * 86400000).toISOString() : null; +} + +function matchFilter(filter, payload) { + if (!filter || typeof filter !== 'object') return true; + const { field, op = 'eq', value } = filter; + const actual = payload ? payload[field] : undefined; + switch (op) { + case 'neq': return actual != value; // eslint-disable-line eqeqeq + case 'truthy': return Boolean(actual); + case 'falsy': return !actual; + case 'eq': + default: return actual == value; // eslint-disable-line eqeqeq + } +} + +async function recordStep(runId, node, status, result, error) { + await db('workflow_run_steps').insert({ + run_id: runId, + node_key: node.node_key, + node_type: node.type, + status, + result: result ? JSON.stringify(result) : null, + error: error || null, + finished_at: db.fn.now(), + }); +} + +async function failRun(runId, error) { + await db('workflow_runs').where({ id: runId }).update({ status: 'failed', error, finished_at: db.fn.now() }); + logger.error('[workflow] run failed', { runId, error }); +} + +async function finishRun(runId) { + await db('workflow_runs').where({ id: runId }).update({ status: 'done', finished_at: db.fn.now() }); +} + +/** + * Walk the graph from the run's current node until it ends, fails, or pauses + * (wait / gate). Persists context + current_node after each node. + */ +async function advanceRun(runId) { + let run = await db('workflow_runs').where({ id: runId }).first(); + if (!run || run.status !== 'running') return; + const { nodeByKey, edges } = await loadGraph(run.workflow_id, run.version); + const context = parseJson(run.context, { vars: {} }); + if (!context.vars) context.vars = {}; + + let currentKey = run.current_node; + let steps = 0; + + while (currentKey) { + if (++steps > MAX_STEPS_PER_ADVANCE) { await failRun(runId, 'max steps per advance exceeded'); return; } + const node = nodeByKey.get(currentKey); + if (!node) { await failRun(runId, `node not found: ${currentKey}`); return; } + + const ctx = { run, node, vars: context.vars, db, logger }; + let nextKey = null; + + try { + switch (node.type) { + case 'trigger': { + const e = outEdge(edges, currentKey, null); + nextKey = e ? e.to_node : null; + await recordStep(runId, node, 'done', null); + break; + } + case 'condition': + case 'branch': { + const cond = registry.getCondition(node.config?.condition || 'expr'); + const result = cond ? await cond(ctx) : false; + const handle = result ? (node.config?.trueHandle || 'yes') : (node.config?.falseHandle || 'no'); + const e = outEdge(edges, currentKey, handle) || outEdge(edges, currentKey, result ? 'true' : 'false'); + nextKey = e ? e.to_node : null; + await recordStep(runId, node, 'done', { result, handle }); + break; + } + case 'loop': { + const counterKey = `__loop_${node.node_key}`; + const count = (Number(context.vars[counterKey]) || 0) + 1; + context.vars[counterKey] = count; + const max = Number(node.config?.maxIterations ?? node.config?.max ?? 3); + const handle = count > max ? (node.config?.exitHandle || 'exit') : (node.config?.loopHandle || 'loop'); + const e = outEdge(edges, currentKey, handle); + nextKey = e ? e.to_node : null; + await recordStep(runId, node, 'done', { count, max, handle }); + break; + } + case 'wait': { + const wakeAt = computeWakeAt(node.config, context.vars); + await db('workflow_runs').where({ id: runId }) + .update({ status: 'waiting', wake_at: wakeAt, current_node: currentKey, context: JSON.stringify(context) }); + await recordStep(runId, node, 'waiting', { wake_at: wakeAt }); + return; // paused — scheduler resumes when wake_at passes + } + case 'gate': { + await db('workflow_runs').where({ id: runId }) + .update({ status: 'waiting', wake_at: gateTimeout(node.config), current_node: currentKey, context: JSON.stringify(context) }); + await recordStep(runId, node, 'waiting', { gate: true }); + // Optional setup hook (create approval + send admin email) — registered + // by the approval phase. Engine still pauses cleanly without it. + const setup = registry.getAction('gate_setup'); + if (setup) { + try { await setup(ctx); } catch (e) { logger.error('[workflow] gate setup failed', { runId, error: e.message }); } + } + return; // paused — an approval (email or inbox) resumes via resumeRun + } + case 'action': + case 'webhook': { + const actionKey = node.config?.action || (node.type === 'webhook' ? 'webhook' : 'noop'); + const action = registry.getAction(actionKey); + const result = action ? (await action(ctx)) || {} : { skipped: true, reason: `unknown action ${actionKey}` }; + if (result.set && typeof result.set === 'object') Object.assign(context.vars, result.set); + const e = outEdge(edges, currentKey, null); + nextKey = e ? e.to_node : null; + await recordStep(runId, node, result.skipped ? 'skipped' : 'done', result); + break; + } + default: { + await recordStep(runId, node, 'skipped', { reason: `unknown node type ${node.type}` }); + const e = outEdge(edges, currentKey, null); + nextKey = e ? e.to_node : null; + } + } + } catch (err) { + await recordStep(runId, node, 'failed', null, err.message); + await failRun(runId, `node ${currentKey} failed: ${err.message}`); + return; + } + + currentKey = nextKey; + await db('workflow_runs').where({ id: runId }).update({ current_node: currentKey || null, context: JSON.stringify(context) }); + } + + await finishRun(runId); +} + +/** Begin a freshly-created run at its trigger node. */ +async function startRun(runId) { + const run = await db('workflow_runs').where({ id: runId }).first(); + if (!run || ['done', 'failed', 'cancelled'].includes(run.status)) return; + const { nodeByKey } = await loadGraph(run.workflow_id, run.version); + let entry = null; + for (const n of nodeByKey.values()) { if (n.type === 'trigger') { entry = n; break; } } + if (!entry) { await failRun(runId, 'no trigger node'); return; } + await db('workflow_runs').where({ id: runId }).update({ status: 'running', current_node: entry.node_key }); + await advanceRun(runId); +} + +/** + * Resume a paused (waiting) run. For a wait node, pass no handle. For a gate, + * pass decisionHandle = 'confirm' | 'deny' so the matching edge is taken. + */ +async function resumeRun(runId, { decisionHandle = null } = {}) { + const run = await db('workflow_runs').where({ id: runId }).first(); + if (!run || run.status !== 'waiting') return; + const { edges } = await loadGraph(run.workflow_id, run.version); + const e = outEdge(edges, run.current_node, decisionHandle); + const nextKey = e ? e.to_node : null; + await db('workflow_runs').where({ id: runId }).update({ status: 'running', current_node: nextKey, wake_at: null }); + if (!nextKey) { await finishRun(runId); return; } + await advanceRun(runId); +} + +/** + * Entry point for lifecycle events. Creates one run per matching enabled + * workflow (idempotent via dedup_key) and starts it. Never throws — safe to + * call after a caller's commit. Fails CLOSED if the flag system is unavailable. + */ +async function emitWorkflowEvent(triggerType, { entityType = null, entityId = null, payload = {} } = {}) { + try { + const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag'); + let enabled = false; + try { enabled = await isFeatureEnabled('workflows'); } catch (e) { + logger.warn('[workflow] flag check failed — treating workflows as disabled', { error: e.message }); + return []; + } + if (!enabled) return []; + + const workflows = await db('workflows').where({ enabled: true, trigger_type: triggerType }); + const runIds = []; + for (const wf of workflows) { + const tcfg = parseJson(wf.trigger_config, {}); + if (tcfg && tcfg.filter && !matchFilter(tcfg.filter, payload)) continue; + + const dedupKey = `${wf.id}:${wf.version}:${triggerType}:${entityType || ''}:${entityId || ''}`; + const existing = await db('workflow_runs').where({ dedup_key: dedupKey }).first(); + if (existing) continue; + + try { + await db('workflow_runs').insert({ + workflow_id: wf.id, + version: wf.version, + trigger_event: triggerType, + entity_type: entityType, + entity_id: entityId, + status: 'pending', + context: JSON.stringify({ vars: { ...payload } }), + dedup_key: dedupKey, + }); + } catch (e) { + continue; // unique race — another emitter created it + } + const row = await db('workflow_runs').where({ dedup_key: dedupKey }).first(); + if (!row) continue; + runIds.push(row.id); + await startRun(row.id).catch((err) => logger.error('[workflow] start failed', { runId: row.id, error: err.message })); + } + return runIds; + } catch (e) { + logger.error('[workflow] emit failed', { triggerType, error: e.message }); + return []; + } +} + +module.exports = { + emitWorkflowEvent, + startRun, + advanceRun, + resumeRun, + finishRun, + failRun, + // exported for tests / introspection + loadGraph, + outEdge, + computeWakeAt, +}; diff --git a/backend/src/services/workflows/index.js b/backend/src/services/workflows/index.js new file mode 100644 index 00000000..cfff36bf --- /dev/null +++ b/backend/src/services/workflows/index.js @@ -0,0 +1,16 @@ +/** + * Workflow engine public surface. + * + * const { emitWorkflowEvent } = require('./services/workflows'); + * + * `engine` holds the executor (start/advance/resume), `registry` the catalog + * of conditions/actions. Action/condition handler modules require `registry` + * and call registerAction/registerCondition at load time. + */ +const engine = require('./engine'); +const registry = require('./registry'); + +module.exports = { + ...engine, + registry, +}; diff --git a/backend/src/services/workflows/registry.js b/backend/src/services/workflows/registry.js new file mode 100644 index 00000000..ae21ca87 --- /dev/null +++ b/backend/src/services/workflows/registry.js @@ -0,0 +1,61 @@ +/** + * Workflow registry — the curated catalog of CONDITIONS and ACTIONS the engine + * can run. Node `config.condition` / `config.action` keys map to handlers here. + * + * Handlers are async `(ctx) => result`, where ctx = { run, node, vars, db, + * logger }. `vars` is the run's mutable context bag (loop counters, accumulated + * values, the trigger payload). A condition returns a boolean; an action may + * return `{ set: {...} }` to merge values back into `vars`. + * + * Keep handlers curated and typed — this is NOT arbitrary code execution. New + * triggers/actions register here; the canvas palette is derived from these. + */ +const conditions = new Map(); +const actions = new Map(); + +function registerCondition(key, fn) { conditions.set(key, fn); } +function registerAction(key, fn) { actions.set(key, fn); } +function getCondition(key) { return conditions.get(key); } +function getAction(key) { return actions.get(key); } +function listConditions() { return Array.from(conditions.keys()); } +function listActions() { return Array.from(actions.keys()); } + +// --- Primitive conditions --- +registerCondition('always', async () => true); +registerCondition('never', async () => false); +// Generic field/op/value compare against the run's `vars` bag. +registerCondition('expr', async (ctx) => { + const { field, op = 'truthy', value } = ctx.node.config || {}; + const actual = field != null ? ctx.vars[field] : undefined; + switch (op) { + case 'eq': return actual == value; // eslint-disable-line eqeqeq + case 'neq': return actual != value; // eslint-disable-line eqeqeq + case 'gt': return Number(actual) > Number(value); + case 'gte': return Number(actual) >= Number(value); + case 'lt': return Number(actual) < Number(value); + case 'lte': return Number(actual) <= Number(value); + case 'falsy': return !actual; + case 'truthy': + default: return Boolean(actual); + } +}); + +// --- Primitive actions --- +registerAction('noop', async () => ({})); +registerAction('log', async (ctx) => { + ctx.logger?.info?.('[workflow] log action', { runId: ctx.run.id, message: ctx.node.config?.message }); + return { logged: true }; +}); +// Merge a static object into the run context (handy for tests + seeding flags). +registerAction('set_context', async (ctx) => ({ set: ctx.node.config?.set || {} })); + +module.exports = { + registerCondition, + registerAction, + getCondition, + getAction, + listConditions, + listActions, + conditions, + actions, +}; From 610a3dfd732fbf37c68bc3edacaed6c99610a2ab Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:09:57 +0200 Subject: [PATCH 04/43] feat(workflows): scheduler resumes elapsed wait nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds engine.runDueWaits() — polls waiting runs whose wake_at has passed and resumes the ones parked on a wait node (gate timeouts handled later by the approvals layer). Flag-gated (fails closed when workflows is off). Wired into the existing hourly invoiceScheduler tick in its own try/catch so a workflow failure never suppresses the invoice/reminder jobs. Test covers not-due vs elapsed resume. --- .../integration/workflowEngine.test.js | 24 +++++++++++ .../src/services/invoiceSchedulerService.js | 9 ++++ backend/src/services/workflows/engine.js | 41 +++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index 65119b6f..433b169c 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -134,4 +134,28 @@ describe('workflow engine', () => { run = await db('workflow_runs').where({ id: run0.id }).first(); expect(run.status).toBe('done'); }); + + test('runDueWaits resumes only elapsed wait nodes', async () => { + await makeWorkflow({ + trigger: 'wait.event', + nodes: [ + { key: 'w1', type: 'trigger' }, + { key: 'w2', type: 'wait', config: { delayMinutes: 60 } }, + { key: 'w3', type: 'action', config: { action: 'noop' } }, + ], + edges: [{ from: 'w1', to: 'w2' }, { from: 'w2', to: 'w3' }], + }); + const runIds = await engine.emitWorkflowEvent('wait.event', { entityType: 'e', entityId: 7 }); + const runId = runIds[0]; + let run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('waiting'); + + expect(await engine.runDueWaits()).toBe(0); // wake_at ~60min out → not due + + await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() }); + const resumed = await engine.runDueWaits(); + expect(resumed).toBeGreaterThanOrEqual(1); + run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('done'); + }); }); diff --git a/backend/src/services/invoiceSchedulerService.js b/backend/src/services/invoiceSchedulerService.js index 181ab99e..614349b1 100644 --- a/backend/src/services/invoiceSchedulerService.js +++ b/backend/src/services/invoiceSchedulerService.js @@ -42,6 +42,15 @@ async function runTick() { } catch (err) { logger.error('Event reminder pass failed', { err: err.message }); } + try { + // Resume workflow runs whose wait has elapsed. No-op (fails closed) when + // the `workflows` feature flag is off. Independent try/catch so a workflow + // failure never suppresses the invoice/reminder jobs above. + const resumed = await require('./workflows').runDueWaits(); + if (resumed) logger.info('Workflow scheduler: resumed waiting runs', { resumed }); + } catch (err) { + logger.error('Workflow resume pass failed', { err: err.message }); + } } function startInvoiceScheduler() { diff --git a/backend/src/services/workflows/engine.js b/backend/src/services/workflows/engine.js index 97caa737..b4bb401f 100644 --- a/backend/src/services/workflows/engine.js +++ b/backend/src/services/workflows/engine.js @@ -270,8 +270,49 @@ async function emitWorkflowEvent(triggerType, { entityType = null, entityId = nu } } +/** + * Resume runs whose wait has elapsed. Called from the cron scheduler tick. + * Only advances `wait` nodes — gate timeouts are handled by the approvals + * layer. Fails CLOSED if the workflows flag is off (master kill-switch). + */ +async function runDueWaits(limit = 100) { + try { + const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag'); + let enabled = false; + try { enabled = await isFeatureEnabled('workflows'); } catch (e) { return 0; } + if (!enabled) return 0; + + const nowIso = new Date().toISOString(); + const due = await db('workflow_runs') + .where({ status: 'waiting' }) + .whereNotNull('wake_at') + .where('wake_at', '<=', nowIso) + .limit(limit); + + let resumed = 0; + for (const run of due) { + try { + const node = await db('workflow_nodes') + .where({ workflow_id: run.workflow_id, version: run.version, node_key: run.current_node }) + .first(); + if (node && node.type === 'wait') { + await resumeRun(run.id); + resumed += 1; + } + } catch (err) { + logger.error('[workflow] runDueWaits item failed', { runId: run.id, error: err.message }); + } + } + return resumed; + } catch (e) { + logger.error('[workflow] runDueWaits failed', { error: e.message }); + return 0; + } +} + module.exports = { emitWorkflowEvent, + runDueWaits, startRun, advanceRun, resumeRun, From 96fb44045e49c4cbc9b221780f7c725f80751993 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:28:02 +0200 Subject: [PATCH 05/43] feat(workflows): data-touching action + condition handlers Adds send_email (INTERNAL/admin = immediate, EXTERNAL/customer = business- hours floor via queueEmail's respectBusinessHours) and the invoice_paid condition (paid_at / status / cumulative paid_amount). Registers the prepare_quote/contract/event/gallery/invoice + send_document + reserve_date document actions as recognized-but-not-yet-wired (record an observable skipped step rather than crashing a flow). index.js side-effect-imports the handlers. Tests cover the customer-mail routing + the invoice_paid logic. --- .../integration/workflowEngine.test.js | 29 +++++++ backend/src/services/workflows/actions.js | 78 +++++++++++++++++++ backend/src/services/workflows/index.js | 3 + 3 files changed, 110 insertions(+) create mode 100644 backend/src/services/workflows/actions.js diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index 433b169c..a448a9c7 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -158,4 +158,33 @@ describe('workflow engine', () => { run = await db('workflow_runs').where({ id: runId }).first(); expect(run.status).toBe('done'); }); + + test('send_email queues a customer mail with business-hours routing', async () => { + await makeWorkflow({ + trigger: 'mail.event', + nodes: [ + { key: 'm1', type: 'trigger' }, + { key: 'm2', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'workflow_test' } }, + ], + edges: [{ from: 'm1', to: 'm2' }], + }); + const runIds = await engine.emitWorkflowEvent('mail.event', { + entityType: 'invoice', entityId: 3, payload: { customerEmail: 'cust@example.com' }, + }); + const run = await db('workflow_runs').where({ id: runIds[0] }).first(); + expect(run.status).toBe('done'); + const queued = await db('email_queue').where({ recipient_email: 'cust@example.com' }).first(); + expect(queued).toBeTruthy(); + const step = await db('workflow_run_steps').where({ run_id: runIds[0], node_key: 'm2' }).first(); + expect(JSON.parse(step.result).respectBusinessHours).toBe(true); + }); + + test('invoice_paid condition reads the entity', async () => { + const registry = require('../../src/services/workflows/registry'); + const cond = registry.getCondition('invoice_paid'); + const makeCtx = (row) => ({ run: { entity_id: 1 }, db: () => ({ where: () => ({ first: async () => row }) }) }); + expect(await cond(makeCtx({ paid_at: '2026-01-01', status: 'sent' }))).toBe(true); + 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); + }); }); diff --git a/backend/src/services/workflows/actions.js b/backend/src/services/workflows/actions.js new file mode 100644 index 00000000..7b3077cd --- /dev/null +++ b/backend/src/services/workflows/actions.js @@ -0,0 +1,78 @@ +/** + * Workflow action + condition handlers that touch real picpeak data. + * + * Registered at load time (index.js requires this module). Kept separate from + * registry.js (which holds only primitives) so the I/O-coupled handlers don't + * bloat the pure core. + * + * Email routing rule (locked requirement): INTERNAL/admin mail sends + * immediately; EXTERNAL/customer mail respects the business-hours floor. The + * action sets queueEmail's `respectBusinessHours` from the recipient class. + * + * The create/prepare-document actions (quote/contract/event/gallery/invoice) + * are registered so flows validate, but are intentionally NOT wired to the + * services yet — they record a `skipped` step with a clear reason so the gap + * is observable rather than silent. Wiring is a follow-up commit. + */ +const registry = require('./registry'); + +const DOCUMENT_ACTIONS = [ + 'prepare_quote', + 'prepare_contract', + 'prepare_event', + 'prepare_gallery', + 'prepare_invoice', + 'send_document', + 'reserve_date', +]; + +// --- Conditions --- + +// True once the run's invoice entity is settled (paid_at set, status paid, or +// the cumulative paid amount covers the total). +registry.registerCondition('invoice_paid', async (ctx) => { + const id = ctx.run.entity_id; + if (!id) return false; + const inv = await ctx.db('invoices').where({ id }).first(); + if (!inv) return false; + if (inv.paid_at) return true; + if (inv.status === 'paid') return true; + const paid = Number(inv.paid_amount_minor) || 0; + const total = Number(inv.total_amount_minor); + return Number.isFinite(total) && total > 0 && paid >= total; +}); + +// --- Actions --- + +// Queue an email. recipientClass 'admin' (internal) sends immediately; +// anything else (customer/external) respects the business-hours floor. +registry.registerAction('send_email', async (ctx) => { + const cfg = ctx.node.config || {}; + const recipientClass = cfg.recipientClass || cfg.recipient || 'customer'; + const isInternal = recipientClass === 'admin' || recipientClass === 'internal'; + const to = cfg.to + || ctx.vars[isInternal ? 'adminEmail' : 'customerEmail'] + || ctx.vars.recipientEmail; + if (!to) return { skipped: true, reason: 'no recipient resolved' }; + + const emailProcessor = require('../emailProcessor'); + const eventId = ctx.vars.eventId || null; + const emailType = cfg.emailType || cfg.template || 'workflow_notification'; + const emailData = { ...(cfg.emailData || {}), ...(ctx.vars.emailData || {}) }; + + // INTERNAL/admin = immediate; EXTERNAL/customer = business-hours floor. + const respectBusinessHours = !isInternal; + await emailProcessor.queueEmail(eventId, to, emailType, emailData, { respectBusinessHours }); + return { sent_to: to, recipientClass, respectBusinessHours }; +}); + +// Create/prepare-document actions — registered so flows referencing them are +// valid; service wiring is a follow-up. Records a skipped step (observable). +for (const key of DOCUMENT_ACTIONS) { + registry.registerAction(key, async (ctx) => { + ctx.logger?.warn?.('[workflow] document action not yet wired', { action: key, runId: ctx.run.id }); + return { skipped: true, reason: `action ${key} not yet implemented` }; + }); +} + +module.exports = { DOCUMENT_ACTIONS }; diff --git a/backend/src/services/workflows/index.js b/backend/src/services/workflows/index.js index cfff36bf..1c386ed3 100644 --- a/backend/src/services/workflows/index.js +++ b/backend/src/services/workflows/index.js @@ -9,6 +9,9 @@ */ const engine = require('./engine'); 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'); module.exports = { ...engine, From cc0ba5347d36161e3da2eaff3a1f2d56c582c4a5 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:33:14 +0200 Subject: [PATCH 06/43] feat(workflows): emit lifecycle events from invoice + quote services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the workflow event bus into the hot paths, AFTER each commit: - invoiceService.sendInvoice → invoice.sent (idempotent per invoice id, so overdue re-sends don't double-fire) - invoiceService.markPaid → invoice.paid, only on the transition into paid (transaction result captured so the emit runs post-commit, never rolling back a recorded payment) - quoteService.recordResponse / adminAcceptQuote / adminDeclineQuote → quote.accepted / quote.declined via a shared emitQuoteEvent helper that resolves the customer email for downstream send_email actions All emits are best-effort and fail closed when the workflows flag is off. Existing invoice/quote integration tests still green. --- backend/src/services/invoiceService.js | 44 +++++++++++++++++++++++++- backend/src/services/quoteService.js | 34 ++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index b25400bd..821e9186 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -2039,6 +2039,28 @@ async function sendInvoice(id, adminId) { }); try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) {} + + // Fire the workflow engine's invoice.sent trigger (after the row is updated + + // the email queued). Idempotent per invoice id; no-op when the workflows flag + // is off. Never throws into the send path. + try { + await require('./workflows').emitWorkflowEvent('invoice.sent', { + entityType: 'invoice', + entityId: id, + payload: { + invoiceId: id, + invoiceNumber: invoice.invoice_number, + eventId: invoice.event_id || null, + customerAccountId: invoice.customer_account_id, + customerEmail: invoiceTo, + dueDate: invoice.due_date, + issueDate: invoice.issue_date, + totalMinor: invoice.total_amount_minor, + currency: invoice.currency, + }, + }); + } catch (_) {} + return { sent: true, pdfPath }; } @@ -2068,7 +2090,7 @@ async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, not ? Math.max(0, ensureInt(invoice.total_amount_minor) - amount) : null; - return await db.transaction(async (trx) => { + const markResult = await db.transaction(async (trx) => { await trx('invoice_payment_log').insert({ invoice_id: id, amount_minor: amount, @@ -2145,6 +2167,26 @@ async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, not return { paidTotalMinor: total, status: isFull ? 'paid' : invoice.status }; }); + + // Fire invoice.paid for the workflow engine ONLY on the transition into + // 'paid' (mirrors the admin-notification guard above). After the commit so a + // workflow side effect can never roll back the recorded payment. + if (markResult.status === 'paid' && invoice.status !== 'paid') { + try { + await require('./workflows').emitWorkflowEvent('invoice.paid', { + entityType: 'invoice', + entityId: id, + payload: { + invoiceId: id, + invoiceNumber: invoice.invoice_number, + eventId: invoice.event_id || null, + customerAccountId: invoice.customer_account_id, + paidTotalMinor: markResult.paidTotalMinor, + }, + }); + } catch (_) {} + } + return markResult; } /** diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index 8c83686e..3db3614b 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -1022,6 +1022,34 @@ async function persistDocPdf(type, doc, buffer) { * the same token may flip accept↔decline. After the window expires the * response is locked. */ +/** + * Fire a quote lifecycle event for the workflow engine. Best-effort: resolves + * the customer email (so send_email actions have a recipient) and never throws + * into the caller. No-op when the workflows flag is off (emit fails closed). + */ +async function emitQuoteEvent(quote, status) { + try { + let customerEmail = null; + if (quote.customer_account_id) { + const c = await db('customer_accounts').where({ id: quote.customer_account_id }).first(); + customerEmail = c?.email || null; + } + await require('./workflows').emitWorkflowEvent(`quote.${status}`, { + entityType: 'quote', + entityId: quote.id, + payload: { + quoteId: quote.id, + quoteNumber: quote.quote_number, + customerAccountId: quote.customer_account_id || null, + customerEmail, + eventName: quote.event_name || null, + eventDate: quote.event_date || null, + totalMinor: quote.total_amount_minor ?? null, + }, + }); + } catch (_) { /* best-effort */ } +} + async function recordResponse({ token, action, ip, tosAccepted }) { if (!['accept', 'decline'].includes(action)) { throw new AppError('Invalid action', 400); @@ -1105,6 +1133,8 @@ async function recordResponse({ token, action, ip, tosAccepted }) { await logActivity(`quote_${newStatus}`, { quoteId: quote.id, token: tokenRow.token }, null, 'customer:public'); } catch (_) {} + await emitQuoteEvent(quote, newStatus); + return { status: newStatus, lockedAt: responseLockedAt }; } @@ -1202,6 +1232,8 @@ async function adminAcceptQuote(id, adminId) { logger.warn('quote_accepted_customer email queue failed', { quoteId: id, err: err.message }); } + await emitQuoteEvent(quote, 'accepted'); + return { status: 'accepted', lockedAt: responseLockedAt }; } @@ -1265,6 +1297,8 @@ async function adminDeclineQuote(id, adminId, reason = null) { await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`); } catch (_) {} + await emitQuoteEvent(quote, 'declined'); + return { status: 'declined', declinedAt: now }; } From b48d8c2eb8e278d3dad8a8afef221a640bcac919 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:37:20 +0200 Subject: [PATCH 07/43] =?UTF-8?q?feat(workflows):=20approval=20gates=20?= =?UTF-8?q?=E2=80=94=20email=20confirm/deny=20+=20token=20resume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../integration/workflowEngine.test.js | 46 +++++++ backend/server.js | 1 + backend/src/routes/publicWorkflowApprovals.js | 50 +++++++ backend/src/services/workflows/approvals.js | 126 ++++++++++++++++++ backend/src/services/workflows/index.js | 3 + 5 files changed, 226 insertions(+) create mode 100644 backend/src/routes/publicWorkflowApprovals.js create mode 100644 backend/src/services/workflows/approvals.js 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, }; From 1a0d6de04d3547ca0e34833befea200c2750e859 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:40:29 +0200 Subject: [PATCH 08/43] feat(workflows): admin CRUD + run-history + approvals-inbox API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET/POST/PUT/PATCH/DELETE /api/admin/workflows with graph read/write (PUT writes a fresh node/edge set under version+1 and bumps workflows.version so in-flight runs keep their pinned version). Run-history (/:id/runs, /runs/:runId/steps) and the pending-approval inbox (GET /approvals, POST /approvals/:id/:action → actById) round it out. Gated by the workflows flag + RBAC (view for reads, manage for writes); built-in flows refuse delete; graph validated (exactly one trigger, unique keys, edges reference known nodes). Route tests cover CRUD, validation, version bump, toggle, inbox, and the 403 permission gate. --- .../integration/workflowRoutes.test.js | 102 +++++++++ backend/server.js | 1 + backend/src/routes/adminWorkflows.js | 201 ++++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 backend/__tests__/integration/workflowRoutes.test.js create mode 100644 backend/src/routes/adminWorkflows.js diff --git a/backend/__tests__/integration/workflowRoutes.test.js b/backend/__tests__/integration/workflowRoutes.test.js new file mode 100644 index 00000000..e0b8024b --- /dev/null +++ b/backend/__tests__/integration/workflowRoutes.test.js @@ -0,0 +1,102 @@ +/** + * Admin workflow API — route tests (CRUD, versioning, RBAC gate, approvals). + */ +const request = require('supertest'); +const { + bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp, +} = require('./helpers/crmDb'); + +let db; +let cleanup; +let app; +let token; +let noPermToken; + +const sampleGraph = { + name: 'Test flow', + trigger_type: 'invoice.sent', + enabled: false, + nodes: [ + { node_key: 'n1', type: 'trigger' }, + { node_key: 'n2', type: 'action', config: { action: 'noop' } }, + ], + edges: [{ from_node: 'n1', to_node: 'n2' }], +}; + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + const { adminId } = await seedMinimal(db); + await assignAdminRole(db, adminId, 'super_admin'); + token = mintAdminToken(adminId); + + const ins = await db('admin_users').insert({ + username: 'norole', email: 'nr@example.com', password_hash: 'x', + must_change_password: false, created_at: new Date(), + }).returning('id'); + noPermToken = mintAdminToken(ins[0]?.id ?? ins[0]); + + await db('feature_flags').insert({ key: 'workflows', value: true }); + app = buildRouteApp('/api/admin/workflows', require('../../src/routes/adminWorkflows')); +}); + +afterAll(async () => { await cleanup(); }); + +const auth = (t) => ({ Authorization: `Bearer ${t}` }); + +describe('admin workflows API', () => { + let createdId; + + test('create → 201 with id', async () => { + const res = await request(app).post('/api/admin/workflows').set(auth(token)).send(sampleGraph); + expect(res.status).toBe(201); + expect(res.body.id).toBeGreaterThan(0); + createdId = res.body.id; + }); + + test('rejects a graph without exactly one trigger', async () => { + const res = await request(app).post('/api/admin/workflows').set(auth(token)) + .send({ ...sampleGraph, nodes: [{ node_key: 'x', type: 'action' }], edges: [] }); + expect(res.status).toBe(400); + }); + + test('get one returns the graph', async () => { + const res = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token)); + expect(res.status).toBe(200); + expect(res.body.nodes).toHaveLength(2); + expect(res.body.edges).toHaveLength(1); + expect(res.body.version).toBe(1); + }); + + test('list includes it', async () => { + const res = await request(app).get('/api/admin/workflows').set(auth(token)); + expect(res.status).toBe(200); + expect(res.body.some((w) => w.id === createdId)).toBe(true); + }); + + test('update bumps the version', async () => { + const res = await request(app).put(`/api/admin/workflows/${createdId}`).set(auth(token)) + .send({ ...sampleGraph, name: 'Renamed' }); + expect(res.status).toBe(200); + expect(res.body.version).toBe(2); + const get = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token)); + expect(get.body.name).toBe('Renamed'); + expect(get.body.version).toBe(2); + }); + + test('enable toggle', async () => { + const res = await request(app).patch(`/api/admin/workflows/${createdId}/enabled`).set(auth(token)).send({ enabled: true }); + expect(res.status).toBe(200); + expect(res.body.enabled).toBe(true); + }); + + test('approvals inbox returns an array', async () => { + const res = await request(app).get('/api/admin/workflows/approvals').set(auth(token)); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + test('a role without workflows.manage is forbidden from writing', async () => { + const res = await request(app).post('/api/admin/workflows').set(auth(noPermToken)).send(sampleGraph); + expect(res.status).toBe(403); + }); +}); diff --git a/backend/server.js b/backend/server.js index 9200a1a7..4fd62cb9 100644 --- a/backend/server.js +++ b/backend/server.js @@ -707,6 +707,7 @@ app.use('/api/admin/contracts', require('./src/routes/adminContracts')); app.use('/api/admin/projects', require('./src/routes/adminProjects')); app.use('/api/admin/calendar', require('./src/routes/adminCalendar')); app.use('/api/admin/deals', require('./src/routes/adminDeals')); +app.use('/api/admin/workflows', require('./src/routes/adminWorkflows')); app.use('/api/admin/tax-report', require('./src/routes/adminTaxReport')); app.use('/api/admin/expenses', require('./src/routes/adminExpenses')); app.use('/api/admin/ledger', require('./src/routes/adminLedger')); diff --git a/backend/src/routes/adminWorkflows.js b/backend/src/routes/adminWorkflows.js new file mode 100644 index 00000000..4392fdba --- /dev/null +++ b/backend/src/routes/adminWorkflows.js @@ -0,0 +1,201 @@ +/** + * Admin workflow management API. + * + * GET /api/admin/workflows list + * GET /api/admin/workflows/approvals pending-approval inbox + * POST /api/admin/workflows/approvals/:id/:act confirm|deny (webview) + * GET /api/admin/workflows/runs/:runId/steps run step audit + * GET /api/admin/workflows/:id/runs run history + * GET /api/admin/workflows/:id one workflow + its graph + * POST /api/admin/workflows create + * PUT /api/admin/workflows/:id update (bumps version) + * PATCH /api/admin/workflows/:id/enabled enable/disable + * DELETE /api/admin/workflows/:id delete (built-ins refused) + * + * Versioning: editing writes a fresh node/edge set under version+1 and bumps + * workflows.version; in-flight runs keep executing the version they pinned. + * All endpoints gated by the `workflows` feature flag + RBAC (view/manage). + */ +const express = require('express'); + +const router = express.Router(); +const { db } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const { requireFeatureFlag } = require('../middleware/requireFeatureFlag'); +const workflows = require('../services/workflows'); + +router.use(adminAuth, requireFeatureFlag('workflows')); + +function parseJson(v, fallback) { + if (v == null) return fallback; + if (typeof v === 'object') return v; + try { return JSON.parse(v); } catch (e) { return fallback; } +} + +function validateGraph(body) { + const nodes = Array.isArray(body.nodes) ? body.nodes : []; + const edges = Array.isArray(body.edges) ? body.edges : []; + const triggers = nodes.filter((n) => n.type === 'trigger'); + if (triggers.length !== 1) return 'A workflow must have exactly one trigger node'; + if (nodes.some((n) => !n.node_key || !n.type)) return 'Every node needs a node_key and type'; + const keys = new Set(nodes.map((n) => n.node_key)); + if (keys.size !== nodes.length) return 'Duplicate node_key in graph'; + for (const e of edges) { + if (!keys.has(e.from_node) || !keys.has(e.to_node)) return 'Edge references an unknown node'; + } + return null; +} + +async function writeGraph(trx, workflowId, version, nodes = [], edges = []) { + for (const n of nodes) { + await trx('workflow_nodes').insert({ + workflow_id: workflowId, version, node_key: n.node_key, type: n.type, + config: JSON.stringify(n.config || {}), pos_x: n.pos_x || 0, pos_y: n.pos_y || 0, + }); + } + for (const e of edges) { + await trx('workflow_edges').insert({ + workflow_id: workflowId, version, from_node: e.from_node, from_handle: e.from_handle || null, + to_node: e.to_node, label: e.label || null, loop_back: !!e.loop_back, + }); + } +} + +// --- Approvals inbox (registered before /:id so 'approvals' isn't an id) --- +router.get('/approvals', requirePermission('workflows.view'), async (req, res, next) => { + try { + const items = await workflows.listPending(); + res.json(items.map((a) => ({ ...a, payload: parseJson(a.payload, {}) }))); + } catch (e) { next(e); } +}); + +router.post('/approvals/:id/:action', requirePermission('workflows.manage'), async (req, res, next) => { + try { + const { action } = req.params; + if (!['confirm', 'deny'].includes(action)) return res.status(400).json({ error: 'Invalid action' }); + const result = await workflows.actById(Number(req.params.id), action, req.admin?.id); + if (!result.ok && result.reason === 'not_found') return res.status(404).json({ error: 'Approval not found' }); + if (!result.ok && result.reason === 'expired') return res.status(410).json({ error: 'Approval expired' }); + res.json(result); + } catch (e) { next(e); } +}); + +// --- Run history --- +router.get('/runs/:runId/steps', requirePermission('workflows.view'), async (req, res, next) => { + try { + const steps = await db('workflow_run_steps').where({ run_id: Number(req.params.runId) }).orderBy('id', 'asc'); + res.json(steps.map((s) => ({ ...s, result: parseJson(s.result, null) }))); + } catch (e) { next(e); } +}); + +router.get('/:id/runs', requirePermission('workflows.view'), async (req, res, next) => { + try { + const runs = await db('workflow_runs').where({ workflow_id: Number(req.params.id) }).orderBy('id', 'desc').limit(200); + res.json(runs.map((r) => ({ ...r, context: parseJson(r.context, {}) }))); + } catch (e) { next(e); } +}); + +// --- List / get --- +router.get('/', requirePermission('workflows.view'), async (req, res, next) => { + try { + const rows = await db('workflows').orderBy('id', 'desc'); + res.json(rows.map((w) => ({ ...w, trigger_config: parseJson(w.trigger_config, null) }))); + } catch (e) { next(e); } +}); + +router.get('/:id', requirePermission('workflows.view'), async (req, res, next) => { + try { + const wf = await db('workflows').where({ id: Number(req.params.id) }).first(); + if (!wf) return res.status(404).json({ error: 'Workflow not found' }); + const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version }); + const edges = await db('workflow_edges').where({ workflow_id: wf.id, version: wf.version }); + res.json({ + ...wf, + trigger_config: parseJson(wf.trigger_config, null), + nodes: nodes.map((n) => ({ ...n, config: parseJson(n.config, {}) })), + edges, + }); + } catch (e) { next(e); } +}); + +// --- Create / update / toggle / delete --- +router.post('/', requirePermission('workflows.manage'), async (req, res, next) => { + try { + const b = req.body || {}; + if (!b.name || !b.trigger_type) return res.status(400).json({ error: 'name and trigger_type are required' }); + const err = validateGraph(b); + if (err) return res.status(400).json({ error: err }); + const id = await db.transaction(async (trx) => { + const ins = await trx('workflows').insert({ + name: b.name, description: b.description || null, enabled: !!b.enabled, version: 1, + trigger_type: b.trigger_type, trigger_config: b.trigger_config ? JSON.stringify(b.trigger_config) : null, + created_by: req.admin?.id || null, + }); + const newId = ins[0]; + await writeGraph(trx, newId, 1, b.nodes, b.edges); + return newId; + }); + res.status(201).json({ id }); + } catch (e) { next(e); } +}); + +router.put('/:id', requirePermission('workflows.manage'), async (req, res, next) => { + try { + const id = Number(req.params.id); + const wf = await db('workflows').where({ id }).first(); + if (!wf) return res.status(404).json({ error: 'Workflow not found' }); + const b = req.body || {}; + const err = validateGraph(b); + if (err) return res.status(400).json({ error: err }); + const newVersion = wf.version + 1; + await db.transaction(async (trx) => { + await trx('workflows').where({ id }).update({ + name: b.name ?? wf.name, + description: b.description ?? wf.description, + enabled: b.enabled != null ? !!b.enabled : wf.enabled, + trigger_type: b.trigger_type ?? wf.trigger_type, + trigger_config: b.trigger_config !== undefined + ? (b.trigger_config ? JSON.stringify(b.trigger_config) : null) + : wf.trigger_config, + version: newVersion, + updated_at: trx.fn.now(), + }); + await writeGraph(trx, id, newVersion, b.nodes, b.edges); + }); + res.json({ id, version: newVersion }); + } catch (e) { next(e); } +}); + +router.patch('/:id/enabled', requirePermission('workflows.manage'), async (req, res, next) => { + try { + const id = Number(req.params.id); + const enabled = !!(req.body && req.body.enabled); + const updated = await db('workflows').where({ id }).update({ enabled, updated_at: db.fn.now() }); + if (!updated) return res.status(404).json({ error: 'Workflow not found' }); + res.json({ id, enabled }); + } catch (e) { next(e); } +}); + +router.delete('/:id', requirePermission('workflows.manage'), async (req, res, next) => { + try { + const id = Number(req.params.id); + const wf = await db('workflows').where({ id }).first(); + if (!wf) return res.status(404).json({ error: 'Workflow not found' }); + if (wf.is_builtin) return res.status(409).json({ error: 'Built-in workflows cannot be deleted' }); + await db.transaction(async (trx) => { + const runIds = (await trx('workflow_runs').where({ workflow_id: id }).select('id')).map((r) => r.id); + if (runIds.length) { + await trx('workflow_run_steps').whereIn('run_id', runIds).del(); + await trx('workflow_approvals').whereIn('run_id', runIds).del(); + } + await trx('workflow_runs').where({ workflow_id: id }).del(); + await trx('workflow_edges').where({ workflow_id: id }).del(); + await trx('workflow_nodes').where({ workflow_id: id }).del(); + await trx('workflows').where({ id }).del(); + }); + res.json({ deleted: true }); + } catch (e) { next(e); } +}); + +module.exports = router; From 9b557efbf347ba585ceac61cfc0b6b3038ef7de6 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:44:05 +0200 Subject: [PATCH 09/43] feat(workflows): seed invoice-dunning ladder as an editable built-in flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot self-heal seeds the corrected gate-in-loop dunning graph (wait→due, grace wait, invoice_paid check, confirm-no-payment gate, bounded reminder loop with re-check, final notice) keyed on builtin_key='invoice_dunning', sized from the reminder_first/second_days settings. Seeded DISABLED and is_builtin: live reminder behaviour is UNCHANGED (the hardcoded scheduler ladder still runs) — enabling it pre-cutover would double-send, so the engine cutover is a deliberate follow-up. Idempotent (preserves admin edits). Built-ins refuse delete (enforced in the CRUD route). Test covers seed shape + idempotency. --- .../integration/workflowEngine.test.js | 19 ++++ backend/server.js | 9 ++ backend/src/services/_workflowSeedBoot.js | 102 ++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 backend/src/services/_workflowSeedBoot.js diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index c57f4939..a038e62e 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -233,4 +233,23 @@ describe('workflow engine', () => { const again = await engine.actByToken(rawToken, 'confirm'); expect(again.already).toBe(true); }); + + test('seeds the invoice-dunning built-in flow (disabled, idempotent)', async () => { + const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot'); + const noopLogger = { info() {}, warn() {} }; + await seedBuiltinWorkflowsAtBoot(db, noopLogger); + + const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first(); + expect(wf).toBeTruthy(); + expect(!!wf.is_builtin).toBe(true); + expect(!!wf.enabled).toBe(false); + + const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: 1 }); + expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1); + expect(nodes.length).toBeGreaterThanOrEqual(10); + + await seedBuiltinWorkflowsAtBoot(db, noopLogger); // idempotent + const all = await db('workflows').where({ builtin_key: DUNNING_KEY }); + expect(all.length).toBe(1); + }); }); diff --git a/backend/server.js b/backend/server.js index 4fd62cb9..6b71ea3f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -897,6 +897,15 @@ async function startServer() { logger.warn('restore-settings self-heal failed at boot:', err.message); } + // Seed built-in workflows (the editable invoice-dunning flow). Disabled by + // default — live reminder behaviour is unchanged. See _workflowSeedBoot.js. + try { + const { seedBuiltinWorkflowsAtBoot } = require('./src/services/_workflowSeedBoot'); + await seedBuiltinWorkflowsAtBoot(db, logger); + } catch (err) { + logger.warn('built-in workflow seed failed at boot:', err.message); + } + // Install-from-backup trigger. If `RESTORE_ON_INSTALL` (or // `.txt`) exists in the /backup mount AND the DB is empty, run // the restore HERE before any admin UI surfaces. Lets admins diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js new file mode 100644 index 00000000..01a74175 --- /dev/null +++ b/backend/src/services/_workflowSeedBoot.js @@ -0,0 +1,102 @@ +/** + * Boot-time seed for built-in workflows. + * + * Seeds the invoice-dunning ladder as an EDITABLE built-in flow (the corrected + * gate-in-loop graph), so the canvas has real content and admins can see their + * reminder process as blocks. Seeded from the current reminder settings. + * + * IMPORTANT — seeded DISABLED, and live behaviour is UNCHANGED: the existing + * hardcoded reminder ladder in invoiceService.runScheduledTasks still runs. The + * cutover (drive reminders through the engine + stop the hardcoded ladder) is a + * deliberate follow-up so we never double-send. Enabling this flow before that + * cutover would duplicate reminders — hence default off. + * + * Idempotent: keyed on builtin_key='invoice_dunning'. Once seeded, admin edits + * are preserved (we never overwrite an existing built-in). Self-heal pattern + * per [[feedback_self_heal_pattern]]. + */ +const { getAppSetting } = require('../utils/appSettings'); + +const DUNNING_KEY = 'invoice_dunning'; + +function buildDunningGraph({ firstDays, gapDays, maxReminders }) { + const nodes = [ + { node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 }, + { node_key: 'waitDue', type: 'wait', config: { untilVar: 'dueDate' }, pos_x: 240, pos_y: 110 }, + { node_key: 'waitGrace', type: 'wait', config: { delayDays: firstDays }, pos_x: 240, pos_y: 220 }, + { node_key: 'checkPaid', type: 'condition', config: { condition: 'invoice_paid' }, pos_x: 240, pos_y: 330 }, + { node_key: 'gate', type: 'gate', config: { type: 'payment_confirm', prompt: 'No payment received yet — send a reminder?' }, pos_x: 240, pos_y: 440 }, + { node_key: 'loop', type: 'loop', config: { maxIterations: maxReminders }, pos_x: 240, pos_y: 550 }, + { node_key: 'remind', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'invoice_reminder' }, pos_x: 240, pos_y: 660 }, + { node_key: 'waitGap', type: 'wait', config: { delayDays: gapDays }, pos_x: 240, pos_y: 770 }, + { node_key: 'final', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'invoice_final_notice' }, pos_x: 520, pos_y: 660 }, + { node_key: 'doneEnd', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 770 }, + { node_key: 'donePaid', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 330 }, + ]; + const edges = [ + { from_node: 't', to_node: 'waitDue' }, + { from_node: 'waitDue', to_node: 'waitGrace' }, + { from_node: 'waitGrace', to_node: 'checkPaid' }, + { from_node: 'checkPaid', from_handle: 'yes', to_node: 'donePaid' }, + { from_node: 'checkPaid', from_handle: 'no', to_node: 'gate' }, + { from_node: 'gate', from_handle: 'confirm', to_node: 'loop' }, + { from_node: 'gate', from_handle: 'deny', to_node: 'donePaid' }, + { from_node: 'loop', from_handle: 'loop', to_node: 'remind' }, + { from_node: 'loop', from_handle: 'exit', to_node: 'final' }, + { from_node: 'remind', to_node: 'waitGap' }, + { from_node: 'waitGap', to_node: 'checkPaid', loop_back: true }, + { from_node: 'final', to_node: 'doneEnd' }, + ]; + return { nodes, edges }; +} + +let booted = false; + +async function seedBuiltinWorkflowsAtBoot(db, logger) { + try { + if (!(await db.schema.hasTable('workflows'))) return; + const existing = await db('workflows').where({ builtin_key: DUNNING_KEY }).first(); + if (existing) { booted = true; return; } + + const firstDays = Number(await getAppSetting('crm_invoices_reminder_first_days')) || 14; + const secondDays = Number(await getAppSetting('crm_invoices_reminder_second_days')) || 30; + const gapDays = Math.max(1, secondDays - firstDays); + const { nodes, edges } = buildDunningGraph({ firstDays, gapDays, maxReminders: 2 }); + + await db.transaction(async (trx) => { + const ins = await trx('workflows').insert({ + name: 'Invoice dunning (built-in)', + description: 'Editable copy of the overdue-reminder ladder: wait to due date, ' + + 'confirm-no-payment gate, then up to two reminders before a final notice. ' + + 'Disabled by default — the live reminder ladder still runs via the scheduler ' + + 'until an explicit cutover, so enabling this without that change would double-send.', + enabled: false, + version: 1, + trigger_type: 'invoice.sent', + trigger_config: null, + is_builtin: true, + builtin_key: DUNNING_KEY, + }); + const workflowId = ins[0]; + for (const n of nodes) { + await trx('workflow_nodes').insert({ + workflow_id: workflowId, version: 1, node_key: n.node_key, type: n.type, + config: JSON.stringify(n.config || {}), pos_x: n.pos_x || 0, pos_y: n.pos_y || 0, + }); + } + for (const e of edges) { + await trx('workflow_edges').insert({ + workflow_id: workflowId, version: 1, from_node: e.from_node, from_handle: e.from_handle || null, + to_node: e.to_node, label: e.label || null, loop_back: !!e.loop_back, + }); + } + }); + + booted = true; + logger?.info?.('Seeded built-in workflow: invoice dunning (disabled)'); + } catch (err) { + logger?.warn?.('Built-in workflow seed failed at boot:', err.message); + } +} + +module.exports = { seedBuiltinWorkflowsAtBoot, buildDunningGraph, DUNNING_KEY }; From 5c0396d0c1bf69ba48b42eacc6d8f145b901a299 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:51:40 +0200 Subject: [PATCH 10/43] feat(workflows): React Flow canvas editor + list + approvals UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the admin Workflows surface (top-level nav, gated by the workflows flag + workflows.view): a list page (enable toggle, delete, new), a pending-approvals inbox (confirm/deny), and a React Flow (@xyflow/react) canvas editor — palette to add nodes, drag handle→handle to connect (branch/gate/loop expose yes-no / confirm-deny / loop-exit handles), a side-panel JSON config editor, and save (writes a new version). Routes + sidebar entry + workflows.service. Build + tsc clean. NOTE: the workflow page strings render via inline English fallbacks; DE translations for the workflows.* block are still pending native review. --- frontend/package-lock.json | 235 +++++++++++++++++- frontend/package.json | 1 + frontend/src/App.tsx | 11 + .../src/components/admin/AdminSidebar.tsx | 7 + frontend/src/i18n/locales/de.json | 1 + frontend/src/i18n/locales/en.json | 1 + .../admin/workflows/WorkflowApprovalsPage.tsx | 81 ++++++ .../admin/workflows/WorkflowEditorPage.tsx | 229 +++++++++++++++++ .../admin/workflows/WorkflowsListPage.tsx | 135 ++++++++++ frontend/src/services/workflows.service.ts | 92 +++++++ 10 files changed, 791 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/admin/workflows/WorkflowApprovalsPage.tsx create mode 100644 frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx create mode 100644 frontend/src/pages/admin/workflows/WorkflowsListPage.tsx create mode 100644 frontend/src/services/workflows.service.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9aac8719..414aaecb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "picpeak-frontend", - "version": "3.47.2-beta.0", + "version": "3.69.0-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-frontend", - "version": "3.47.2-beta.0", + "version": "3.69.0-beta.0", "dependencies": { "@fullcalendar/core": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20", @@ -25,6 +25,7 @@ "@types/dompurify": "^3.0.5", "@types/lodash": "^4.17.20", "@types/react-google-recaptcha": "^2.1.9", + "@xyflow/react": "^12.11.1", "axios": "1.15.2", "clsx": "^2.0.0", "date-fns": "4.1.0", @@ -3460,6 +3461,55 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3996,6 +4046,48 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.1.tgz", + "integrity": "sha512-L+zBoLGSXham0MnlY8QqjfR7/C5JNw0zxkaey5aZ5XmCgJBAdH4+WRIu8CR40d3l/BdU635V6YbhBK1jMo8/6Q==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.78", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.78", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.78.tgz", + "integrity": "sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4435,6 +4527,12 @@ "node": ">= 6" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -4634,6 +4732,111 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -9297,6 +9500,34 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index 2a6b6033..da03cbb7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ "@types/dompurify": "^3.0.5", "@types/lodash": "^4.17.20", "@types/react-google-recaptcha": "^2.1.9", + "@xyflow/react": "^12.11.1", "axios": "1.15.2", "clsx": "^2.0.0", "date-fns": "4.1.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 169a0297..959628d3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -46,6 +46,9 @@ import { QuoteResponsePage } from './pages/public/QuoteResponsePage'; import { ContractResponsePage } from './pages/public/ContractResponsePage'; import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage'; import { ProjectCockpitPage } from './pages/admin/projects/ProjectCockpitPage'; +import { WorkflowsListPage } from './pages/admin/workflows/WorkflowsListPage'; +import { WorkflowApprovalsPage } from './pages/admin/workflows/WorkflowApprovalsPage'; +import { WorkflowEditorPage } from './pages/admin/workflows/WorkflowEditorPage'; import { ContractsListPage } from './pages/admin/contracts/ContractsListPage'; import { ContractEditorPage } from './pages/admin/contracts/ContractEditorPage'; import { ContractDetailPage } from './pages/admin/contracts/ContractDetailPage'; @@ -314,6 +317,14 @@ function App() { } /> } /> + {/* Workflows (automation engine) — top-level area gated + by the `workflows` flag. */} + }> + } /> + } /> + } /> + + } /> } /> } /> diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index 2dbd26d0..6a18cd09 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -11,6 +11,7 @@ import { Users, Briefcase, Landmark, + Workflow, PanelLeftClose, PanelLeftOpen, } from 'lucide-react'; @@ -110,6 +111,12 @@ const navigation: NavItem[] = [ permission: 'accounting.view', featureFlag: 'accounting', }, + // Workflows (automation engine) — top-level, gated by the `workflows` flag. + { + nameKey: 'navigation.workflows', href: '/admin/workflows', icon: Workflow, + permission: 'workflows.view', + featureFlag: 'workflows', + }, ]; export const AdminSidebar: React.FC = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => { diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 9b680e94..474cda48 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -199,6 +199,7 @@ "calendar": "Kalender", "clients": "CRM", "accounting": "Buchhaltung", + "workflows": "Workflows", "betaTag": "Beta" }, "eventTypes": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index cf3c178b..1b86df49 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -199,6 +199,7 @@ "calendar": "Calendar", "clients": "CRM", "accounting": "Accounting", + "workflows": "Workflows", "betaTag": "Beta" }, "archives": { diff --git a/frontend/src/pages/admin/workflows/WorkflowApprovalsPage.tsx b/frontend/src/pages/admin/workflows/WorkflowApprovalsPage.tsx new file mode 100644 index 00000000..6f4f7eed --- /dev/null +++ b/frontend/src/pages/admin/workflows/WorkflowApprovalsPage.tsx @@ -0,0 +1,81 @@ +/** + * Admin → Workflows → Approvals inbox. Lists workflow runs paused on a gate + * (e.g. "confirm there's no payment") and lets the admin confirm or deny, + * resuming the run down the matching edge. The same decision is also possible + * from the emailed confirm/deny link. + */ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { ArrowLeft, Check, X } from 'lucide-react'; +import { Button, Card, Loading } from '../../../components/common'; +import { workflowsService, type WorkflowApproval } from '../../../services/workflows.service'; +import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; + +export const WorkflowApprovalsPage: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const qc = useQueryClient(); + const { formatDateTime } = useLocalizedDate(); + + const { data: approvals, isLoading } = useQuery({ + queryKey: ['workflow-approvals'], + queryFn: () => workflowsService.approvals(), + }); + + const actMutation = useMutation({ + mutationFn: ({ id, action }: { id: number; action: 'confirm' | 'deny' }) => workflowsService.actApproval(id, action), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['workflow-approvals'] }); + toast.success(t('workflows.approvals.recorded', 'Response recorded') as string); + }, + onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)), + }); + + const promptOf = (a: WorkflowApproval) => (a.payload && (a.payload.prompt as string)) || t('workflows.approvals.defaultPrompt', 'A workflow needs your confirmation.'); + + return ( +
+
+ +
+

{t('workflows.approvals.title', 'Approvals')}

+

{t('workflows.approvals.subtitle', 'Workflow runs waiting on your confirmation.')}

+
+
+ + + {isLoading ? ( +
+ ) : !approvals || approvals.length === 0 ? ( +
{t('workflows.approvals.empty', 'Nothing waiting for you right now.')}
+ ) : ( +
    + {approvals.map((a) => ( +
  • +
    +
    {promptOf(a)}
    +
    + {a.workflow_name} + {a.entity_type ? ` · ${a.entity_type} #${a.entity_id}` : ''} + {a.created_at ? ` · ${formatDateTime(a.created_at)}` : ''} +
    +
    + + +
  • + ))} +
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx new file mode 100644 index 00000000..52feaba5 --- /dev/null +++ b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx @@ -0,0 +1,229 @@ +/** + * Admin → Workflows → canvas editor (React Flow). + * + * Drag nodes from the palette, connect handle→handle (branch/gate/loop expose + * yes/no · confirm/deny · loop/exit handles), click a node to edit its config + * in the side panel, and Save (writes a new version; in-flight runs keep + * theirs). The graph maps 1:1 onto workflow_nodes/workflow_edges. + */ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { + ReactFlow, Background, Controls, MiniMap, addEdge, useNodesState, useEdgesState, + Handle, Position, type Connection, type Node, type Edge, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import { ArrowLeft, Save, Trash2 } from 'lucide-react'; +import { Button, Loading } from '../../../components/common'; +import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service'; + +const PALETTE: WorkflowNodeType[] = ['trigger', 'condition', 'branch', 'loop', 'wait', 'action', 'gate', 'webhook']; +const TRIGGERS = [ + 'invoice.sent', 'invoice.paid', 'invoice.overdue', 'quote.accepted', 'quote.declined', + 'contract.signed', 'event.date_approaching', 'gallery.published', 'gallery.expiring', 'customer.created', +]; + +const COLORS: Record = { + trigger: '#1D9E75', condition: '#BA7517', branch: '#BA7517', loop: '#378ADD', + wait: '#888780', action: '#534AB7', gate: '#7F77DD', webhook: '#888780', +}; + +const SOURCE_HANDLES: Record = { + condition: ['yes', 'no'], branch: ['yes', 'no'], gate: ['confirm', 'deny'], loop: ['loop', 'exit'], +}; + +function WfNode({ data }: { data: { label: string; nodeType: string } }) { + const handles = SOURCE_HANDLES[data.nodeType]; + const color = COLORS[data.nodeType] || '#888780'; + return ( +
+ {data.nodeType !== 'trigger' && } +
{data.nodeType}
+
{data.label}
+ {handles ? ( + handles.map((h, i) => ( + + + )) + ) : ( + + )} +
+ ); +} + +const nodeTypes = { wf: WfNode }; + +export const WorkflowEditorPage: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const qc = useQueryClient(); + const { id } = useParams<{ id: string }>(); + const workflowId = Number(id); + + const { data: workflow, isLoading } = useQuery({ + queryKey: ['workflow', workflowId], + queryFn: () => workflowsService.get(workflowId), + enabled: Number.isFinite(workflowId), + }); + + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [name, setName] = useState(''); + const [triggerType, setTriggerType] = useState('invoice.sent'); + const [enabled, setEnabled] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [configText, setConfigText] = useState('{}'); + const [counter, setCounter] = useState(1); + + useEffect(() => { + if (!workflow) return; + setName(workflow.name); + setTriggerType(workflow.trigger_type); + setEnabled(workflow.enabled === true || workflow.enabled === 1); + setNodes(workflow.nodes.map((n) => ({ + id: n.node_key, + type: 'wf', + position: { x: n.pos_x || 0, y: n.pos_y || 0 }, + data: { label: n.node_key, nodeType: n.type, config: n.config || {} }, + }))); + setEdges(workflow.edges.map((e, i) => ({ + id: `e${i}`, + source: e.from_node, + target: e.to_node, + sourceHandle: e.from_handle || undefined, + label: e.from_handle || undefined, + }))); + }, [workflow, setNodes, setEdges]); + + const onConnect = useCallback((c: Connection) => { + setEdges((eds) => addEdge({ ...c, label: c.sourceHandle || undefined }, eds)); + }, [setEdges]); + + const addNode = (type: WorkflowNodeType) => { + const key = type === 'trigger' ? 'trigger' : `${type}_${counter}`; + setCounter((c) => c + 1); + setNodes((nds) => nds.concat({ + id: key, type: 'wf', position: { x: 120 + Math.random() * 240, y: 120 + Math.random() * 240 }, + data: { label: key, nodeType: type, config: {} }, + })); + }; + + const selectedNode = useMemo(() => nodes.find((n) => n.id === selectedId) || null, [nodes, selectedId]); + useEffect(() => { + if (selectedNode) setConfigText(JSON.stringify((selectedNode.data as any).config || {}, null, 2)); + }, [selectedNode]); + + const applyConfig = () => { + if (!selectedId) return; + let parsed: Record; + try { parsed = JSON.parse(configText || '{}'); } catch (e) { toast.error(t('workflows.editor.badJson', 'Config is not valid JSON') as string); return; } + setNodes((nds) => nds.map((n) => (n.id === selectedId ? { ...n, data: { ...n.data, config: parsed } } : n))); + toast.success(t('workflows.editor.configApplied', 'Config applied (remember to Save)') as string); + }; + + const deleteSelected = () => { + if (!selectedId) return; + setNodes((nds) => nds.filter((n) => n.id !== selectedId)); + setEdges((eds) => eds.filter((e) => e.source !== selectedId && e.target !== selectedId)); + setSelectedId(null); + }; + + const saveMutation = useMutation({ + mutationFn: () => workflowsService.update(workflowId, { + name: name.trim() || 'Untitled', + trigger_type: triggerType, + enabled, + nodes: nodes.map((n) => ({ + node_key: n.id, type: (n.data as any).nodeType, config: (n.data as any).config || {}, + pos_x: Math.round(n.position.x), pos_y: Math.round(n.position.y), + })), + edges: edges.map((e) => ({ + from_node: e.source, from_handle: e.sourceHandle || null, to_node: e.target, + })), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['workflow', workflowId] }); + qc.invalidateQueries({ queryKey: ['workflows'] }); + toast.success(t('workflows.editor.saved', 'Workflow saved') as string); + }, + onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.editor.saveFailed', 'Could not save') as string)), + }); + + if (isLoading) return
; + + return ( +
+
+ + setName(e.target.value)} + className="px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100" + placeholder={t('workflows.editor.namePlaceholder', 'Workflow name') as string} + /> + + +
+ +
+
+ +
+ {PALETTE.map((type) => ( + + ))} +
+ +
+
+ setSelectedId(n.id)} fitView + > + + + + +
+ + {selectedNode && ( +
+
+
{(selectedNode.data as any).nodeType} · {selectedNode.id}
+ +
+ +