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;