feat(workflows): admin CRUD + run-history + approvals-inbox API

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.
This commit is contained in:
Luca
2026-06-23 02:40:29 +02:00
parent b48d8c2eb8
commit 1a0d6de04d
3 changed files with 304 additions and 0 deletions
@@ -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);
});
});
+1
View File
@@ -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'));
+201
View File
@@ -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;